From e08c3eb7c0ff703444388330f9ff41d08f5e3e47 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Thu, 5 Mar 2026 23:30:02 -0800 Subject: [PATCH 01/38] initial versions --- .github/workflows/pr.yml | 60 ++ .gitignore | 3 + .prettierignore | 4 +- CLAUDE.md | 244 +++++++ go/README.md | 128 ++++ go/cmd/beachball/main.go | 77 +++ go/go.mod | 14 + go/go.sum | 14 + go/internal/changefile/change_types.go | 29 + go/internal/changefile/changed_packages.go | 234 +++++++ .../changefile/changed_packages_test.go | 358 ++++++++++ go/internal/changefile/read_change_files.go | 75 ++ go/internal/changefile/write_change_files.go | 60 ++ go/internal/commands/change.go | 61 ++ go/internal/commands/change_test.go | 96 +++ go/internal/commands/check.go | 21 + go/internal/git/commands.go | 235 +++++++ go/internal/git/ensure_shared_history.go | 41 ++ go/internal/git/helpers.go | 27 + go/internal/logging/logging.go | 15 + go/internal/monorepo/filter_ignored.go | 71 ++ go/internal/monorepo/package_groups.go | 56 ++ go/internal/monorepo/package_infos.go | 79 +++ go/internal/monorepo/scoped_packages.go | 81 +++ go/internal/options/get_options.go | 161 +++++ go/internal/options/repo_options.go | 95 +++ go/internal/testutil/change_files.go | 64 ++ go/internal/testutil/fixtures.go | 93 +++ go/internal/testutil/repository.go | 86 +++ go/internal/testutil/repository_factory.go | 112 +++ go/internal/types/change_info.go | 102 +++ go/internal/types/options.go | 66 ++ go/internal/types/package_info.go | 43 ++ .../validation/are_change_files_deleted.go | 17 + go/internal/validation/validate.go | 169 +++++ go/internal/validation/validate_test.go | 76 ++ go/internal/validation/validators.go | 31 + rust/Cargo.lock | 651 ++++++++++++++++++ rust/Cargo.toml | 16 + rust/README.md | 98 +++ rust/src/changefile/change_types.rs | 41 ++ rust/src/changefile/changed_packages.rs | 259 +++++++ rust/src/changefile/mod.rs | 4 + rust/src/changefile/read_change_files.rs | 95 +++ rust/src/changefile/write_change_files.rs | 81 +++ rust/src/commands/change.rs | 79 +++ rust/src/commands/check.rs | 18 + rust/src/commands/mod.rs | 2 + rust/src/git/commands.rs | 286 ++++++++ rust/src/git/ensure_shared_history.rs | 53 ++ rust/src/git/mod.rs | 2 + rust/src/lib.rs | 8 + rust/src/logging.rs | 4 + rust/src/main.rs | 46 ++ rust/src/monorepo/filter_ignored.rs | 25 + rust/src/monorepo/mod.rs | 5 + rust/src/monorepo/package_groups.rs | 71 ++ rust/src/monorepo/package_infos.rs | 98 +++ rust/src/monorepo/path_included.rs | 57 ++ rust/src/monorepo/scoped_packages.rs | 25 + rust/src/options/cli_options.rs | 152 ++++ rust/src/options/default_options.rs | 6 + rust/src/options/get_options.rs | 175 +++++ rust/src/options/mod.rs | 4 + rust/src/options/repo_options.rs | 195 ++++++ rust/src/types/change_info.rs | 106 +++ rust/src/types/mod.rs | 3 + rust/src/types/options.rs | 116 ++++ rust/src/types/package_info.rs | 75 ++ .../validation/are_change_files_deleted.rs | 17 + rust/src/validation/mod.rs | 3 + rust/src/validation/validate.rs | 279 ++++++++ rust/src/validation/validators.rs | 70 ++ rust/tests/change_test.rs | 72 ++ rust/tests/changed_packages_test.rs | 268 +++++++ rust/tests/common/change_files.rs | 55 ++ rust/tests/common/fixtures.rs | 155 +++++ rust/tests/common/mod.rs | 4 + rust/tests/common/repository.rs | 131 ++++ rust/tests/common/repository_factory.rs | 236 +++++++ rust/tests/validate_test.rs | 73 ++ 81 files changed, 7416 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md create mode 100644 go/README.md create mode 100644 go/cmd/beachball/main.go create mode 100644 go/go.mod create mode 100644 go/go.sum create mode 100644 go/internal/changefile/change_types.go create mode 100644 go/internal/changefile/changed_packages.go create mode 100644 go/internal/changefile/changed_packages_test.go create mode 100644 go/internal/changefile/read_change_files.go create mode 100644 go/internal/changefile/write_change_files.go create mode 100644 go/internal/commands/change.go create mode 100644 go/internal/commands/change_test.go create mode 100644 go/internal/commands/check.go create mode 100644 go/internal/git/commands.go create mode 100644 go/internal/git/ensure_shared_history.go create mode 100644 go/internal/git/helpers.go create mode 100644 go/internal/logging/logging.go create mode 100644 go/internal/monorepo/filter_ignored.go create mode 100644 go/internal/monorepo/package_groups.go create mode 100644 go/internal/monorepo/package_infos.go create mode 100644 go/internal/monorepo/scoped_packages.go create mode 100644 go/internal/options/get_options.go create mode 100644 go/internal/options/repo_options.go create mode 100644 go/internal/testutil/change_files.go create mode 100644 go/internal/testutil/fixtures.go create mode 100644 go/internal/testutil/repository.go create mode 100644 go/internal/testutil/repository_factory.go create mode 100644 go/internal/types/change_info.go create mode 100644 go/internal/types/options.go create mode 100644 go/internal/types/package_info.go create mode 100644 go/internal/validation/are_change_files_deleted.go create mode 100644 go/internal/validation/validate.go create mode 100644 go/internal/validation/validate_test.go create mode 100644 go/internal/validation/validators.go create mode 100644 rust/Cargo.lock create mode 100644 rust/Cargo.toml create mode 100644 rust/README.md create mode 100644 rust/src/changefile/change_types.rs create mode 100644 rust/src/changefile/changed_packages.rs create mode 100644 rust/src/changefile/mod.rs create mode 100644 rust/src/changefile/read_change_files.rs create mode 100644 rust/src/changefile/write_change_files.rs create mode 100644 rust/src/commands/change.rs create mode 100644 rust/src/commands/check.rs create mode 100644 rust/src/commands/mod.rs create mode 100644 rust/src/git/commands.rs create mode 100644 rust/src/git/ensure_shared_history.rs create mode 100644 rust/src/git/mod.rs create mode 100644 rust/src/lib.rs create mode 100644 rust/src/logging.rs create mode 100644 rust/src/main.rs create mode 100644 rust/src/monorepo/filter_ignored.rs create mode 100644 rust/src/monorepo/mod.rs create mode 100644 rust/src/monorepo/package_groups.rs create mode 100644 rust/src/monorepo/package_infos.rs create mode 100644 rust/src/monorepo/path_included.rs create mode 100644 rust/src/monorepo/scoped_packages.rs create mode 100644 rust/src/options/cli_options.rs create mode 100644 rust/src/options/default_options.rs create mode 100644 rust/src/options/get_options.rs create mode 100644 rust/src/options/mod.rs create mode 100644 rust/src/options/repo_options.rs create mode 100644 rust/src/types/change_info.rs create mode 100644 rust/src/types/mod.rs create mode 100644 rust/src/types/options.rs create mode 100644 rust/src/types/package_info.rs create mode 100644 rust/src/validation/are_change_files_deleted.rs create mode 100644 rust/src/validation/mod.rs create mode 100644 rust/src/validation/validate.rs create mode 100644 rust/src/validation/validators.rs create mode 100644 rust/tests/change_test.rs create mode 100644 rust/tests/changed_packages_test.rs create mode 100644 rust/tests/common/change_files.rs create mode 100644 rust/tests/common/fixtures.rs create mode 100644 rust/tests/common/mod.rs create mode 100644 rust/tests/common/repository.rs create mode 100644 rust/tests/common/repository_factory.rs create mode 100644 rust/tests/validate_test.rs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index df4616fda..e8fbef300 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -84,3 +84,63 @@ 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@stable + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + rust/target + key: ${{ runner.os }}-cargo-${{ hashFiles('rust/Cargo.lock') }} + + - name: Build + run: cargo build + 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@v5 + with: + go-version-file: go/go.mod + cache-dependency-path: go/go.sum + + - name: Build + run: go build ./... + working-directory: ./go + + - name: Test + run: go test ./... + working-directory: ./go diff --git a/.gitignore b/.gitignore index 49679aa9a..3bc75e0dc 100644 --- a/.gitignore +++ b/.gitignore @@ -8,9 +8,12 @@ lib/ package-lock.json # ignore when switching between yarn 1/4 branches /.yarn +rust/target +.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..aaa384d36 100644 --- a/.prettierignore +++ b/.prettierignore @@ -14,4 +14,6 @@ docs/.vuepress/dist/ LICENSE node_modules/ SECURITY.md -yarn.lock \ No newline at end of file +yarn.lock +rust/ +go/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..80e165377 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,244 @@ +# 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 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/ +yarn start # Watch mode with preserveWatchOutput +``` + +### 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 test:watch # Watch mode +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 +``` + +### Development Workflow + +```bash +yarn change --type minor|patch --message "message" # Create a change file +yarn checkchange # Verify change files exist for modified packages +``` + +## Architecture Overview + +### Core Processing Flow + +Beachball 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`): +Five-pass algorithm that calculates version changes: + +1. Initialize change types from change files +2. Apply package group rules (synchronized versioning) +3. Propagate changes to dependents (if `bumpDeps: true`) +4. Bump package versions in memory +5. Update dependency version ranges + +**Change Files** (`src/changefile/`): + +- Change files stored in `change/` directory track intended version changes +- `readChangeFiles()` - Loads and validates from disk +- `writeChangeFiles()` - Persists new change files +- `unlinkChangeFiles()` - Deletes after consumption during bump +- Each file specifies: package name, change type (patch/minor/major), description, dependent change type + +**Publishing** (`src/publish/`): + +- `publishToRegistry()` - Validates, applies publishConfig, runs hooks, publishes concurrently (respects dependency order) +- `bumpAndPush()` - Git operations: creates temp branch, fetches, merges, bumps, commits, tags, pushes (5 retries) +- Uses temporary `publish_*` branches for safety + +**Context Passing:** +`CommandContext` aggregates reusable data to avoid repeated calculations: + +- `originalPackageInfos` - Discovered packages +- `packageGroups` - Version groups (packages versioned together) +- `scopedPackages` - Filtered set after scoping +- `changeSet` - Validated change files +- `bumpInfo` - Calculated version changes (only if pre-calculated) + +### Important Patterns + +**Immutable-First Design:** + +- In-memory calculations return new objects, don't mutate inputs +- `cloneObject()` creates defensive copies +- Separate calculation (`bumpInMemory`) from application (`performBump`) + +**Validation-First:** + +- `validate()` runs before most commands +- Provides early failure and context for execution +- 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 + +### Critical Implementation Details + +**Change Type Hierarchy:** + +- `none` < `patch` < `minor` < `major` +- Groups can specify `disallowedChangeTypes` (this repo disallows `major`) + +**Scoping:** + +- Filters which packages participate in operations +- Based on git changes, explicit inclusion/exclusion, or package patterns +- Affects change file validation, bumping, and publishing + +**Lock File Handling:** + +- Automatically regenerated after version bumps +- Uses workspace-tools to detect package manager (npm/yarn/pnpm) + +**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:** + +- Unit tests: `__tests__/` or `__fixtures__/` directories +- Functional tests: `__functional__/` directories +- E2E tests: `__e2e__/` directories +- Uses Jest projects to separate test types +- Verdaccio (local npm registry) used for e2e testing + +## Configuration + +The repo uses `beachball.config.js` with: + +- `disallowedChangeTypes: ['major']` - No major version bumps allowed +- `ignorePatterns` - Files/paths that don't require change files (docs, config, tests, yarn.lock) + +## TypeScript Configuration + +- Target: ES2020 (Node 14+ compatible) +- Output: `lib/` directory (compiled JS + declarations) +- Strict mode enabled with `noUnusedLocals` +- Source maps and declaration maps generated +- Checks both TS and JS files (`allowJs`, `checkJs`) + +## Important Notes + +- Beachball has **no public API** - only the CLI and configuration options are supported +- Change files are the source of truth for version bumps (not git commits) +- The `publish` command can run bump, registry publish, and git push independently via flags (`--no-bump`, `--no-publish`, `--no-push`) +- Package manager is auto-detected (supports npm, yarn, pnpm) +- Pre/post hooks available: `prebump`, `postbump`, `prepublish`, `postpublish`, `precommit` + +## Rust and Go Implementations + +The `rust/` and `go/` directories contain parallel re-implementations of beachball's `check` and `change` commands. Both pass 16 tests covering changed package detection, validation, and change file creation. + +### Building and Testing + +```bash +# Rust (from rust/ directory) +cargo build +cargo test + +# Go (from go/ directory) +go build ./... +go test ./... +``` + +### Scope + +Both implement: CLI parsing, JSON config loading (`.beachballrc.json` and `package.json` `"beachball"` field — no JS configs), workspace detection (`workspaces` field), `getChangedPackages` (git diff + file-to-package mapping + change file dedup), `validate()` (minus `bumpInMemory`/dependency validation), non-interactive `change` command (`--type` + `--message`), and `check` command. + +Not implemented: JS config files, interactive prompts, `bumpInMemory`, publish/bump/changelog, pnpm/rush/lerna workspaces. + +### Structure + +- **Rust**: `src/` with nested modules (`types/`, `options/`, `git/`, `monorepo/`, `changefile/`, `validation/`, `commands/`), integration tests in `tests/` with shared helpers in `tests/common/` +- **Go**: `cmd/beachball/` CLI entry, `internal/` packages (`types`, `options`, `git`, `monorepo`, `changefile`, `validation`, `commands`, `logging`), test helpers in `internal/testutil/`, tests alongside source (`_test.go`) + +### Key Implementation Details + +**Git commands**: Both shell out to `git` (matching the TS approach via workspace-tools). Critical flags from workspace-tools: `--no-pager`, `--relative`, `--no-renames`. The `--relative` flag makes diff output relative to cwd (not repo root). 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`. + +**Change file format**: Identical JSON to TS: `{ "type", "comment", "packageName", "email", "dependentChangeType" }`, named `{pkg}-{uuid}.json`. + +### 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. + +### Dependencies + +- **Rust**: clap (CLI), serde/serde_json (JSON), globset/glob (matching), uuid, anyhow, tempfile +- **Go**: cobra (CLI), doublestar (glob), google/uuid, standard library for the rest diff --git a/go/README.md b/go/README.md new file mode 100644 index 000000000..62f983064 --- /dev/null +++ b/go/README.md @@ -0,0 +1,128 @@ +# Beachball (Go) + +A Go re-implementation of beachball's `check` and `change` commands. + +## Prerequisites + +- Go 1.23+ +- `git` on PATH + +## Building + +```bash +go build ./... +go build -o beachball ./cmd/beachball +``` + +## 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 + +``` +beachball check [flags] +beachball change [flags] + +Flags: + -b, --branch string Target branch (default: origin/master) + --path string Path to the repository + -t, --type string Change type: patch, minor, major, none, etc. + -m, --message string Change description + --all Include all packages + --verbose Verbose output + --config-path string Path to beachball config +``` + +## What's Implemented + +- CLI parsing (cobra) +- JSON config loading (`.beachballrc.json`, `package.json` `"beachball"` field) +- Workspace detection (npm/yarn `workspaces` field) +- `getChangedPackages` (git diff + file-to-package mapping + change file dedup) +- `validate()` (minus `bumpInMemory`/dependency validation) +- Non-interactive `change` command (`--type` + `--message`) +- `check` command + +## What's Not Implemented + +- JS config files (`beachball.config.js`) +- Interactive prompts +- `bumpInMemory` / dependency validation +- `publish`, `bump`, `canary`, `sync` commands +- Changelog generation +- pnpm/rush/lerna workspace detection + +## Project Structure + +``` +cmd/beachball/ + main.go # CLI entry point (cobra) +internal/ + types/ + change_info.go # ChangeType, ChangeFileInfo, ChangeSet + package_info.go # PackageJson, PackageInfo, PackageGroups + options.go # BeachballOptions, CliOptions + options/ + get_options.go # Option merging + repo_options.go # Config file loading + git/ + commands.go # Git operations (shell out to git) + helpers.go # File/workspace helpers + ensure_shared_history.go # Fetch/deepen for shallow clones + monorepo/ + package_infos.go # Package discovery + scoped_packages.go # Scope filtering + package_groups.go # Version group resolution + filter_ignored.go # Ignore pattern matching + changefile/ + changed_packages.go # Changed package detection + changed_packages_test.go # 11 tests + read_change_files.go # Read change files from disk + write_change_files.go # Write change files + change_types.go # Disallowed type resolution + validation/ + validate.go # Main validation logic + validate_test.go # 3 tests + validators.go # Type/auth validators + are_change_files_deleted.go # Deleted change file detection + commands/ + check.go # Check command + change.go # Change command + change_test.go # 2 tests + logging/ + logging.go # Output helpers + testutil/ + repository.go # Test git repo wrapper + repository_factory.go # Bare repo + clone factory + fixtures.go # Fixture setup helpers + change_files.go # Test change file helpers +``` diff --git a/go/cmd/beachball/main.go b/go/cmd/beachball/main.go new file mode 100644 index 000000000..8d5ad524c --- /dev/null +++ b/go/cmd/beachball/main.go @@ -0,0 +1,77 @@ +package main + +import ( + "fmt" + "os" + + "github.com/microsoft/beachball/internal/commands" + "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") + + 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) + } + }) + + rootCmd.AddCommand(checkCmd, changeCmd) + + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 000000000..c4bbb9130 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,14 @@ +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 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.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..df1c604a3 --- /dev/null +++ b/go/go.sum @@ -0,0 +1,14 @@ +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/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/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= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/go/internal/changefile/change_types.go b/go/internal/changefile/change_types.go new file mode 100644 index 000000000..3fb3c1fd7 --- /dev/null +++ b/go/internal/changefile/change_types.go @@ -0,0 +1,29 @@ +package changefile + +import "github.com/microsoft/beachball/internal/types" + +// GetDisallowedChangeTypes returns the disallowed change types for a package. +func GetDisallowedChangeTypes( + pkgName string, + packageInfos types.PackageInfos, + packageGroups types.PackageGroups, + options *types.BeachballOptions, +) []string { + // Check package-level disallowed types + if info, ok := packageInfos[pkgName]; ok && info.PackageOptions != nil { + if len(info.PackageOptions.DisallowedChangeTypes) > 0 { + return info.PackageOptions.DisallowedChangeTypes + } + } + + // Check group-level disallowed types + for _, group := range packageGroups { + for _, name := range group.Packages { + if name == pkgName && len(group.DisallowedChangeTypes) > 0 { + return group.DisallowedChangeTypes + } + } + } + + return nil +} diff --git a/go/internal/changefile/changed_packages.go b/go/internal/changefile/changed_packages.go new file mode 100644 index 000000000..b58c2f4df --- /dev/null +++ b/go/internal/changefile/changed_packages.go @@ -0,0 +1,234 @@ +package changefile + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "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 + verbose := options.Verbose + + if options.All { + if verbose { + fmt.Fprintln(os.Stderr, "--all option was provided, so including all packages that are in scope (regardless of changes)") + } + var result []string + for _, pkg := range packageInfos { + included, _ := isPackageIncluded(pkg, scopedPackages) + if included { + result = append(result, pkg.Name) + } + } + return result, nil + } + + fmt.Printf("Checking for changes against %q\n", 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...) + + if verbose { + count := len(changes) + s := "s" + if count == 1 { + s = "" + } + fmt.Printf("Found %d changed file%s in current branch (before filtering)\n", count, s) + } + + 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, verbose) + + if len(nonIgnored) == 0 { + if verbose { + fmt.Fprintln(os.Stderr, "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 { + if verbose { + fmt.Fprintf(os.Stderr, " - ~~%s~~ (%s)\n", file, reason) + } + } else { + includedPackages[pkgInfo.Name] = true + fileCount++ + if verbose { + fmt.Fprintf(os.Stderr, " - %s\n", file) + } + } + } + + if verbose { + pkgCount := len(includedPackages) + fs := "s" + if fileCount == 1 { + fs = "" + } + ps := "s" + if pkgCount == 1 { + ps = "" + } + fmt.Printf("Found %d file%s in %d package%s that should be published\n", fileCount, fs, pkgCount, ps) + } + + 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 + } + } + + if len(existingPackages) > 0 { + var sorted []string + for name := range existingPackages { + sorted = append(sorted, name) + } + fmt.Printf("Your local repository already has change files for these packages:\n%s\n", + 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..f2f49d54c --- /dev/null +++ b/go/internal/changefile/changed_packages_test.go @@ -0,0 +1,358 @@ +package changefile_test + +import ( + "sort" + "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" +) + +const defaultBranch = "master" +const defaultRemoteBranch = "origin/master" + +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 := types.DefaultOptions() + if overrides != nil { + repoOpts = *overrides + } + repoOpts.Branch = defaultRemoteBranch + repoOpts.Fetch = false + + parsed := options.GetParsedOptionsForTest(repo.RootPath(), cli, repoOpts) + packageInfos, err := monorepo.GetPackageInfos(&parsed.Options) + if err != nil { + t.Fatalf("failed to get package infos: %v", err) + } + scopedPackages := monorepo.GetScopedPackages(&parsed.Options, packageInfos) + return parsed.Options, packageInfos, scopedPackages +} + +func checkOutTestBranch(repo *testutil.Repository, name string) { + repo.Checkout("-b", sanitizeBranchName(name), defaultBranch) +} + +func sanitizeBranchName(name string) string { + result := make([]byte, 0, len(name)) + for _, c := range name { + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') { + result = append(result, byte(c)) + } else { + result = append(result, '-') + } + } + return string(result) +} + +// ===== Basic tests ===== + +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) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) != 0 { + t.Fatalf("expected empty list, got: %v", result) + } +} + +func TestReturnsPackageNameWhenChangesInBranch(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + checkOutTestBranch(repo, "changes_in_branch") + repo.CommitChange("packages/foo/myFilename") + + opts, infos, scoped := getOptionsAndPackages(t, repo, nil, nil) + result, err := changefile.GetChangedPackages(&opts, infos, scoped) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) != 1 || result[0] != "foo" { + t.Fatalf("expected [foo], got: %v", result) + } +} + +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) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) != 0 { + t.Fatalf("expected empty list, got: %v", result) + } +} + +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) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) != 1 || result[0] != "foo" { + t.Fatalf("expected [foo], got: %v", result) + } + + cli2 := types.CliOptions{Package: []string{"foo", "bar", "nope"}} + opts2, infos2, scoped2 := getOptionsAndPackages(t, repo, nil, &cli2) + result2, err := changefile.GetChangedPackages(&opts2, infos2, scoped2) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := []string{"foo", "bar", "nope"} + if len(result2) != len(expected) { + t.Fatalf("expected %v, got: %v", expected, result2) + } + for i, v := range expected { + if result2[i] != v { + t.Fatalf("expected %v, got: %v", expected, result2) + } + } +} + +func TestReturnsAllPackagesWithAllTrue(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + + overrides := types.DefaultOptions() + overrides.All = true + opts, infos, scoped := getOptionsAndPackages(t, repo, &overrides, nil) + result, err := changefile.GetChangedPackages(&opts, infos, scoped) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + sort.Strings(result) + expected := []string{"a", "b", "bar", "baz", "foo"} + if len(result) != len(expected) { + t.Fatalf("expected %v, got: %v", expected, result) + } + for i, v := range expected { + if result[i] != v { + t.Fatalf("expected %v, got: %v", expected, result) + } + } +} + +// ===== Single package tests ===== + +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) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) != 0 { + t.Fatalf("expected empty, got: %v", result) + } + + repo.StageChange("foo.js") + result2, err := changefile.GetChangedPackages(&opts, infos, scoped) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result2) != 1 || result2[0] != "foo" { + t.Fatalf("expected [foo], got: %v", result2) + } +} + +func TestRespectsIgnorePatterns(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "single") + repo := factory.CloneRepository() + + overrides := types.DefaultOptions() + 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"}) + + result, err := changefile.GetChangedPackages(&opts, infos, scoped) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) != 0 { + t.Fatalf("expected empty, got: %v", result) + } +} + +// ===== Monorepo tests ===== + +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) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) != 0 { + t.Fatalf("expected empty, got: %v", result) + } + + repo.StageChange("packages/foo/test.js") + result2, err := changefile.GetChangedPackages(&opts, infos, scoped) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result2) != 1 || result2[0] != "foo" { + t.Fatalf("expected [foo], got: %v", result2) + } +} + +func TestExcludesPackagesWithExistingChangeFiles(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + repo.Checkout("-b", "test") + repo.CommitChange("packages/foo/test.js") + + overrides := types.DefaultOptions() + overrides.Verbose = true + opts, infos, scoped := getOptionsAndPackages(t, repo, &overrides, nil) + testutil.GenerateChangeFiles(t, []string{"foo"}, &opts, repo) + + result, err := changefile.GetChangedPackages(&opts, infos, scoped) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) != 0 { + t.Fatalf("expected empty but got: %v", result) + } + + // Change bar => bar is the only changed package returned + repo.StageChange("packages/bar/test.js") + result2, err := changefile.GetChangedPackages(&opts, infos, scoped) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result2) != 1 || result2[0] != "bar" { + t.Fatalf("expected [bar], got: %v", result2) + } +} + +func TestIgnoresPackageChangesAsAppropriate(t *testing.T) { + rootPkg := map[string]interface{}{ + "name": "test-monorepo", + "version": "1.0.0", + "private": true, + "workspaces": []string{"packages/*"}, + } + + packages := map[string]map[string]interface{}{ + "private-pkg": {"name": "private-pkg", "version": "1.0.0", "private": true}, + "no-publish": { + "name": "no-publish", "version": "1.0.0", + "beachball": map[string]interface{}{"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]interface{}{ + "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 := types.DefaultOptions() + overrides.Scope = []string{"!packages/out-of-scope"} + overrides.IgnorePatterns = []string{"**/jest.config.js"} + overrides.Verbose = true + + opts, infos, scoped := getOptionsAndPackages(t, repo, &overrides, nil) + result, err := changefile.GetChangedPackages(&opts, infos, scoped) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) != 1 || result[0] != "publish-me" { + t.Fatalf("expected [publish-me], got: %v", result) + } +} + +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 := types.DefaultOptions() + optsA.Path = pathA + optsA.Branch = defaultRemoteBranch + optsA.Fetch = false + + parsedA := options.GetParsedOptionsForTest(pathA, types.CliOptions{}, optsA) + infosA, err := monorepo.GetPackageInfos(&parsedA.Options) + if err != nil { + t.Fatalf("failed to get package infos: %v", err) + } + scopedA := monorepo.GetScopedPackages(&parsedA.Options, infosA) + resultA, err := changefile.GetChangedPackages(&parsedA.Options, infosA, scopedA) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(resultA) != 1 || resultA[0] != "@project-a/foo" { + t.Fatalf("expected [@project-a/foo], got: %v", resultA) + } + + // Test from project-b root + pathB := repo.PathTo("project-b") + optsB := types.DefaultOptions() + optsB.Path = pathB + optsB.Branch = defaultRemoteBranch + optsB.Fetch = false + + parsedB := options.GetParsedOptionsForTest(pathB, types.CliOptions{}, optsB) + infosB, err := monorepo.GetPackageInfos(&parsedB.Options) + if err != nil { + t.Fatalf("failed to get package infos: %v", err) + } + scopedB := monorepo.GetScopedPackages(&parsedB.Options, infosB) + resultB, err := changefile.GetChangedPackages(&parsedB.Options, infosB, scopedB) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(resultB) != 0 { + t.Fatalf("expected empty, got: %v", resultB) + } +} diff --git a/go/internal/changefile/read_change_files.go b/go/internal/changefile/read_change_files.go new file mode 100644 index 000000000..4a9a846d7 --- /dev/null +++ b/go/internal/changefile/read_change_files.go @@ -0,0 +1,75 @@ +package changefile + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/microsoft/beachball/internal/git" + "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 { + continue + } + + // Try multi format first + var multi types.ChangeInfoMultiple + if err := json.Unmarshal(data, &multi); err == nil && len(multi.Changes) > 0 { + for _, change := range multi.Changes { + if _, ok := packageInfos[change.PackageName]; ok { + if scopedPackages[change.PackageName] { + changeSet = append(changeSet, types.ChangeSetEntry{ + Change: change, + ChangeFile: file, + }) + } + } + } + continue + } + + // Try single format + var single types.ChangeFileInfo + if err := json.Unmarshal(data, &single); err == nil && single.PackageName != "" { + if _, ok := packageInfos[single.PackageName]; ok { + if scopedPackages[single.PackageName] { + changeSet = append(changeSet, types.ChangeSetEntry{ + Change: single, + ChangeFile: file, + }) + } + } + } + } + + return changeSet +} diff --git a/go/internal/changefile/write_change_files.go b/go/internal/changefile/write_change_files.go new file mode 100644 index 000000000..0f9e3eb53 --- /dev/null +++ b/go/internal/changefile/write_change_files.go @@ -0,0 +1,60 @@ +package changefile + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + + "github.com/google/uuid" + "github.com/microsoft/beachball/internal/git" + "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 + + 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) + fmt.Printf("Wrote change file: %s\n", filename) + } + + 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) + } + fmt.Println("Committed change files") + } + } + + return nil +} diff --git a/go/internal/commands/change.go b/go/internal/commands/change.go new file mode 100644 index 000000000..da25a1bb4 --- /dev/null +++ b/go/internal/commands/change.go @@ -0,0 +1,61 @@ +package commands + +import ( + "fmt" + + "github.com/microsoft/beachball/internal/changefile" + "github.com/microsoft/beachball/internal/git" + "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 { + fmt.Println("No changes detected; no change files are needed.") + return nil + } + + options := &parsed.Options + + changeType, err := types.ParseChangeType(options.Type) + if err != nil { + return fmt.Errorf("invalid change type %q: %w", options.Type, err) + } + + 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, err = types.ParseChangeType(options.DependentChangeType) + if err != nil { + return fmt.Errorf("invalid dependent change type: %w", err) + } + } + + var changes []types.ChangeFileInfo + for _, pkg := range result.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..89457ce3b --- /dev/null +++ b/go/internal/commands/change_test.go @@ -0,0 +1,96 @@ +package commands_test + +import ( + "encoding/json" + "os" + "testing" + + "github.com/microsoft/beachball/internal/commands" + "github.com/microsoft/beachball/internal/options" + "github.com/microsoft/beachball/internal/testutil" + "github.com/microsoft/beachball/internal/types" +) + +const defaultBranch = "master" +const defaultRemoteBranch = "origin/master" + +func TestDoesNotCreateChangeFilesWhenNoChanges(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "single") + repo := factory.CloneRepository() + repo.Checkout("-b", "no-changes-test", defaultBranch) + + repoOpts := types.DefaultOptions() + repoOpts.Branch = defaultRemoteBranch + repoOpts.Fetch = false + + cli := types.CliOptions{ + Command: "change", + Message: "test change", + ChangeType: "patch", + } + + parsed := options.GetParsedOptionsForTest(repo.RootPath(), cli, repoOpts) + err := commands.Change(parsed) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + files := testutil.GetChangeFiles(&parsed.Options) + if len(files) != 0 { + t.Fatalf("expected no change files, got %d", len(files)) + } +} + +func TestCreatesChangeFileWithTypeAndMessage(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "single") + repo := factory.CloneRepository() + repo.Checkout("-b", "creates-change-test", defaultBranch) + repo.CommitChange("file.js") + + repoOpts := types.DefaultOptions() + repoOpts.Branch = defaultRemoteBranch + repoOpts.Fetch = false + 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) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + files := testutil.GetChangeFiles(&parsed.Options) + if len(files) != 1 { + t.Fatalf("expected 1 change file, got %d", len(files)) + } + + data, err := os.ReadFile(files[0]) + if err != nil { + t.Fatalf("failed to read change file: %v", err) + } + + var change types.ChangeFileInfo + if err := json.Unmarshal(data, &change); err != nil { + t.Fatalf("failed to parse change file: %v", err) + } + + if change.Type != types.ChangeTypePatch { + t.Fatalf("expected patch, got %s", change.Type) + } + if change.Comment != "test description" { + t.Fatalf("expected 'test description', got %q", change.Comment) + } + if change.PackageName != "foo" { + t.Fatalf("expected 'foo', got %q", change.PackageName) + } + if change.DependentChangeType != types.ChangeTypePatch { + t.Fatalf("expected patch dependent type, got %s", change.DependentChangeType) + } +} diff --git a/go/internal/commands/check.go b/go/internal/commands/check.go new file mode 100644 index 000000000..55f47233d --- /dev/null +++ b/go/internal/commands/check.go @@ -0,0 +1,21 @@ +package commands + +import ( + "fmt" + + "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 + } + + fmt.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..4b707b595 --- /dev/null +++ b/go/internal/git/commands.go @@ -0,0 +1,235 @@ +package git + +import ( + "fmt" + "os/exec" + "path/filepath" + "strings" +) + +// 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) +} + +// FindProjectRoot walks up from cwd looking for a package.json with workspaces. +// Falls back to git root. +func FindProjectRoot(cwd string) (string, error) { + gitRoot, err := FindGitRoot(cwd) + if err != nil { + return "", err + } + + absPath, err := filepath.Abs(cwd) + if err != nil { + return gitRoot, nil + } + gitRootAbs, _ := filepath.Abs(gitRoot) + + dir := absPath + for { + pkgJSON := filepath.Join(dir, "package.json") + if data, err := readFileIfExists(pkgJSON); err == nil && data != nil { + if hasWorkspaces(data) { + return dir, nil + } + } + if dir == gitRootAbs { + break + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + return gitRoot, nil +} + +// 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", "--no-renames", + fmt.Sprintf("%s...", branch), + }, cwd) + if err != nil { + return nil, err + } + if !result.Success { + return nil, nil + } + return splitLines(result.Stdout), nil +} + +// GetStagedChanges returns staged file changes. +func GetStagedChanges(cwd string) ([]string, error) { + result, err := Git([]string{ + "--no-pager", "diff", "--cached", "--name-only", "--relative", "--no-renames", + }, cwd) + if err != nil { + return nil, err + } + if !result.Success { + return nil, nil + } + return splitLines(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", "--no-renames"} + 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 splitLines(result.Stdout), nil +} + +// GetUntrackedChanges returns untracked files. +func GetUntrackedChanges(cwd string) ([]string, error) { + result, err := Git([]string{"status", "--short", "--untracked-files"}, cwd) + if err != nil { + return nil, err + } + var files []string + for _, line := range splitLines(result.Stdout) { + if strings.HasPrefix(line, "??") { + files = append(files, strings.TrimSpace(line[2:])) + } + } + return files, 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 +} + +func splitLines(s string) []string { + if s == "" { + return nil + } + var lines []string + for _, line := range strings.Split(s, "\n") { + if line != "" { + lines = append(lines, line) + } + } + return lines +} diff --git a/go/internal/git/ensure_shared_history.go b/go/internal/git/ensure_shared_history.go new file mode 100644 index 000000000..2d98ef371 --- /dev/null +++ b/go/internal/git/ensure_shared_history.go @@ -0,0 +1,41 @@ +package git + +import ( + "fmt" + "strings" + + "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) + } + + fmt.Printf("Fetching %s from %s...\n", parts[1], parts[0]) + if err := Fetch(options.Branch, cwd); err != nil { + return fmt.Errorf("failed to fetch: %w", err) + } + } + + if IsShallowClone(cwd) { + fmt.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/git/helpers.go b/go/internal/git/helpers.go new file mode 100644 index 000000000..d402a6c78 --- /dev/null +++ b/go/internal/git/helpers.go @@ -0,0 +1,27 @@ +package git + +import ( + "encoding/json" + "os" +) + +func readFileIfExists(path string) ([]byte, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + return data, nil +} + +func hasWorkspaces(data []byte) bool { + var pkg struct { + Workspaces json.RawMessage `json:"workspaces"` + } + if err := json.Unmarshal(data, &pkg); err != nil { + return false + } + return len(pkg.Workspaces) > 0 +} diff --git a/go/internal/logging/logging.go b/go/internal/logging/logging.go new file mode 100644 index 000000000..2f9febe04 --- /dev/null +++ b/go/internal/logging/logging.go @@ -0,0 +1,15 @@ +package logging + +import ( + "fmt" + "strings" +) + +// 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..7897faca6 --- /dev/null +++ b/go/internal/monorepo/filter_ignored.go @@ -0,0 +1,71 @@ +package monorepo + +import ( + "fmt" + "path/filepath" + + "github.com/bmatcuk/doublestar/v4" +) + +// 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, verbose bool) []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 { + if verbose { + fmt.Printf(" - ~~%s~~ (ignored by pattern %q)\n", 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..4198d1667 --- /dev/null +++ b/go/internal/monorepo/package_groups.go @@ -0,0 +1,56 @@ +package monorepo + +import ( + "path/filepath" + + "github.com/bmatcuk/doublestar/v4" + "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 + } + + 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 + } + + return result +} diff --git a/go/internal/monorepo/package_infos.go b/go/internal/monorepo/package_infos.go new file mode 100644 index 000000000..ad69b69cd --- /dev/null +++ b/go/internal/monorepo/package_infos.go @@ -0,0 +1,79 @@ +package monorepo + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/bmatcuk/doublestar/v4" + "github.com/microsoft/beachball/internal/types" +) + +// GetPackageInfos discovers all packages in the workspace. +func GetPackageInfos(options *types.BeachballOptions) (types.PackageInfos, error) { + rootPath := options.Path + rootPkgPath := filepath.Join(rootPath, "package.json") + + data, err := os.ReadFile(rootPkgPath) + if err != nil { + return nil, err + } + + var rootPkg types.PackageJson + if err := json.Unmarshal(data, &rootPkg); err != nil { + return nil, err + } + + infos := make(types.PackageInfos) + + if len(rootPkg.Workspaces) == 0 { + // Single package repo + info := packageInfoFromJSON(&rootPkg, rootPkgPath) + infos[info.Name] = info + return infos, nil + } + + // Monorepo: add root package + rootInfo := packageInfoFromJSON(&rootPkg, rootPkgPath) + infos[rootInfo.Name] = rootInfo + + // Glob for workspace packages + for _, pattern := range rootPkg.Workspaces { + pkgPattern := filepath.Join(rootPath, pattern, "package.json") + // Use doublestar for glob matching + matches, err := doublestar.FilepathGlob(pkgPattern) + if err != nil { + continue + } + for _, match := range matches { + if strings.Contains(match, "node_modules") { + continue + } + pkgData, err := os.ReadFile(match) + if err != nil { + continue + } + var pkg types.PackageJson + if err := json.Unmarshal(pkgData, &pkg); err != nil { + continue + } + absMatch, _ := filepath.Abs(match) + info := packageInfoFromJSON(&pkg, absMatch) + infos[info.Name] = info + } + } + + return infos, 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/scoped_packages.go b/go/internal/monorepo/scoped_packages.go new file mode 100644 index 000000000..e789528ef --- /dev/null +++ b/go/internal/monorepo/scoped_packages.go @@ -0,0 +1,81 @@ +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 + } + + 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/options/get_options.go b/go/internal/options/get_options.go new file mode 100644 index 000000000..7041ba437 --- /dev/null +++ b/go/internal/options/get_options.go @@ -0,0 +1,161 @@ +package options + +import ( + "path/filepath" + + "github.com/microsoft/beachball/internal/git" + "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) + + return types.ParsedOptions{Options: opts, CliOptions: cli}, nil +} + +// GetParsedOptionsForTest creates parsed options for testing with explicit overrides. +func GetParsedOptionsForTest(cwd string, cli types.CliOptions, repoOpts types.BeachballOptions) types.ParsedOptions { + opts := types.DefaultOptions() + opts.Path = cwd + + // Apply repo overrides + if repoOpts.Branch != "" { + opts.Branch = repoOpts.Branch + } + if !repoOpts.Fetch { + opts.Fetch = false + } + if repoOpts.All { + opts.All = true + } + if repoOpts.Verbose { + opts.Verbose = true + } + if repoOpts.Commit { + opts.Commit = repoOpts.Commit + } + if !repoOpts.Commit { + opts.Commit = false + } + if repoOpts.ChangeDir != "" { + opts.ChangeDir = repoOpts.ChangeDir + } + if repoOpts.IgnorePatterns != nil { + opts.IgnorePatterns = repoOpts.IgnorePatterns + } + if repoOpts.Scope != nil { + opts.Scope = repoOpts.Scope + } + if repoOpts.Path != "" { + opts.Path = repoOpts.Path + } + if repoOpts.DisallowDeletedChangeFiles { + opts.DisallowDeletedChangeFiles = true + } + if repoOpts.Groups != nil { + opts.Groups = repoOpts.Groups + } + + // Apply CLI overrides + applyCliOptions(&opts, &cli) + + return types.ParsedOptions{Options: opts, CliOptions: cli} +} + +func applyRepoConfig(opts *types.BeachballOptions, cfg *RepoConfig) { + 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 = cfg.DependentChangeType + } + 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 = cli.ChangeType + } + if cli.Commit != nil { + opts.Commit = *cli.Commit + } + 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..a4908ff4f --- /dev/null +++ b/go/internal/options/repo_options.go @@ -0,0 +1,95 @@ +package options + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/microsoft/beachball/internal/git" + "github.com/microsoft/beachball/internal/types" +) + +// RepoConfig represents beachball config found in a JSON file or package.json. +type RepoConfig struct { + 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"` + 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 searches for beachball config starting from cwd up to the git root. +func LoadRepoConfig(cwd string, configPath string) (*RepoConfig, error) { + if configPath != "" { + return loadConfigFile(configPath) + } + + gitRoot, err := git.FindGitRoot(cwd) + if err != nil { + gitRoot = cwd + } + + absPath, err := filepath.Abs(cwd) + if err != nil { + absPath = cwd + } + gitRootAbs, _ := filepath.Abs(gitRoot) + + dir := absPath + for { + // Try .beachballrc.json + rcPath := filepath.Join(dir, ".beachballrc.json") + if cfg, err := loadConfigFile(rcPath); err == nil { + return cfg, nil + } + + // Try package.json "beachball" field + pkgPath := filepath.Join(dir, "package.json") + if cfg, err := loadFromPackageJSON(pkgPath); err == nil && cfg != nil { + return cfg, nil + } + + if dir == gitRootAbs { + break + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + return nil, nil +} + +func loadConfigFile(path string) (*RepoConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var cfg RepoConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +func loadFromPackageJSON(path string) (*RepoConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var pkg struct { + Beachball *RepoConfig `json:"beachball"` + } + if err := json.Unmarshal(data, &pkg); err != nil { + return nil, err + } + return pkg.Beachball, nil +} diff --git a/go/internal/testutil/change_files.go b/go/internal/testutil/change_files.go new file mode 100644 index 000000000..44703751f --- /dev/null +++ b/go/internal/testutil/change_files.go @@ -0,0 +1,64 @@ +package testutil + +import ( + "encoding/json" + "os" + "path/filepath" + "regexp" + "testing" + + "github.com/google/uuid" + "github.com/microsoft/beachball/internal/types" +) + +var nonAlphanumRe = regexp.MustCompile(`[^a-zA-Z0-9@]`) + +// GenerateChangeFiles creates change files for the given packages and commits them. +func GenerateChangeFiles(t *testing.T, packages []string, options *types.BeachballOptions, repo *Repository) { + t.Helper() + + changePath := filepath.Join(options.Path, options.ChangeDir) + os.MkdirAll(changePath, 0o755) + + for _, pkg := range packages { + id := uuid.New().String() + sanitized := nonAlphanumRe.ReplaceAllString(pkg, "-") + filename := sanitized + "-" + id + ".json" + filePath := filepath.Join(changePath, filename) + + change := types.ChangeFileInfo{ + Type: types.ChangeTypePatch, + Comment: "test change", + PackageName: pkg, + Email: "test@test.com", + DependentChangeType: types.ChangeTypePatch, + } + + data, _ := json.MarshalIndent(change, "", " ") + if err := os.WriteFile(filePath, data, 0o644); err != nil { + t.Fatalf("failed to write change file: %v", err) + } + } + + repo.Git([]string{"add", "-A"}) + if options.Commit { + repo.Git([]string{"commit", "-m", "Change files"}) + } +} + +// 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..a6d25e5f8 --- /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]interface{}) { + 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]interface{}{ + "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]interface{}{ + "name": "monorepo", + "version": "1.0.0", + "private": true, + "workspaces": []string{"packages/*"}, + }) + + packages := map[string]map[string]interface{}{ + "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]interface{}{ + "name": "project-a", + "version": "1.0.0", + "private": true, + "workspaces": []string{"packages/*"}, + }) + writePkgJSON(filepath.Join(projA, "packages", "foo"), map[string]interface{}{ + "name": "@project-a/foo", + "version": "1.0.0", + }) + writePkgJSON(filepath.Join(projA, "packages", "bar"), map[string]interface{}{ + "name": "@project-a/bar", + "version": "1.0.0", + }) + + // Project B + projB := filepath.Join(dir, "project-b") + writePkgJSON(projB, map[string]interface{}{ + "name": "project-b", + "version": "1.0.0", + "private": true, + "workspaces": []string{"packages/*"}, + }) + writePkgJSON(filepath.Join(projB, "packages", "foo"), map[string]interface{}{ + "name": "@project-b/foo", + "version": "1.0.0", + }) +} + +// SetupCustomMonorepo sets up a monorepo with custom package definitions. +func SetupCustomMonorepo(dir string, rootPkg map[string]interface{}, groups map[string]map[string]map[string]interface{}) { + 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/repository.go b/go/internal/testutil/repository.go new file mode 100644 index 000000000..265e362b6 --- /dev/null +++ b/go/internal/testutil/repository.go @@ -0,0 +1,86 @@ +package testutil + +import ( + "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"}) +} diff --git a/go/internal/testutil/repository_factory.go b/go/internal/testutil/repository_factory.go new file mode 100644 index 000000000..60861f89d --- /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]interface{} + customGroups map[string]map[string]map[string]interface{} +} + +// 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]interface{}, groups map[string]map[string]map[string]interface{}) *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..b9c0d84c1 --- /dev/null +++ b/go/internal/types/change_info.go @@ -0,0 +1,102 @@ +package types + +import ( + "encoding/json" + "fmt" +) + +// ChangeType represents the type of version bump. +type ChangeType int + +const ( + ChangeTypeNone ChangeType = iota + ChangeTypePrerelease + ChangeTypePrepatch + ChangeTypePatch + ChangeTypePreminor + ChangeTypeMinor + ChangeTypePremajor + ChangeTypeMajor +) + +var changeTypeStrings = map[ChangeType]string{ + ChangeTypeNone: "none", + ChangeTypePrerelease: "prerelease", + ChangeTypePrepatch: "prepatch", + ChangeTypePatch: "patch", + ChangeTypePreminor: "preminor", + ChangeTypeMinor: "minor", + ChangeTypePremajor: "premajor", + ChangeTypeMajor: "major", +} + +var stringToChangeType = map[string]ChangeType{ + "none": ChangeTypeNone, + "prerelease": ChangeTypePrerelease, + "prepatch": ChangeTypePrepatch, + "patch": ChangeTypePatch, + "preminor": ChangeTypePreminor, + "minor": ChangeTypeMinor, + "premajor": ChangeTypePremajor, + "major": ChangeTypeMajor, +} + +func (c ChangeType) String() string { + if s, ok := changeTypeStrings[c]; ok { + return s + } + return "unknown" +} + +func ParseChangeType(s string) (ChangeType, error) { + if ct, ok := stringToChangeType[s]; ok { + return ct, nil + } + return ChangeTypeNone, fmt.Errorf("invalid change type: %q", s) +} + +func (c ChangeType) MarshalJSON() ([]byte, error) { + return json.Marshal(c.String()) +} + +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 { + _, ok := stringToChangeType[s] + return ok +} + +// 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..d8e4a468c --- /dev/null +++ b/go/internal/types/options.go @@ -0,0 +1,66 @@ +package types + +// BeachballOptions holds all beachball configuration. +type BeachballOptions struct { + All bool + Branch string + Command string + ChangeDir string + ChangeHint string + Commit bool + DependentChangeType string + DisallowDeletedChangeFiles bool + Fetch bool + GroupChanges bool + IgnorePatterns []string + Message string + Package []string + Path string + Scope []string + Type string + Token string + AuthType string + Verbose bool + Groups []VersionGroupOptions +} + +// DefaultOptions returns BeachballOptions with sensible defaults. +func DefaultOptions() BeachballOptions { + return BeachballOptions{ + Branch: "origin/master", + ChangeDir: "change", + ChangeHint: "Run 'beachball change' to create a change file", + Commit: true, + Fetch: true, + } +} + +// VersionGroupOptions configures version groups. +type VersionGroupOptions struct { + Name string `json:"name"` + Include []string `json:"include"` + Exclude []string `json:"exclude,omitempty"` + DisallowedChangeTypes []string `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 + 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..54d3aa382 --- /dev/null +++ b/go/internal/types/package_info.go @@ -0,0 +1,43 @@ +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"` +} + +// PackageOptions represents beachball-specific options in package.json. +type PackageOptions struct { + ShouldPublish *bool `json:"shouldPublish,omitempty"` + DisallowedChangeTypes []string `json:"disallowedChangeTypes,omitempty"` + DefaultNearestBumpType string `json:"defaultNearestBumpType,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 []string +} + +// 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..36a5f2045 --- /dev/null +++ b/go/internal/validation/are_change_files_deleted.go @@ -0,0 +1,17 @@ +package validation + +import ( + "github.com/microsoft/beachball/internal/changefile" + "github.com/microsoft/beachball/internal/git" + "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) + deleted, err := git.GetChangesBetweenRefs(options.Branch, "D", "*.json", changePath) + if err != nil { + return false + } + return len(deleted) > 0 +} diff --git a/go/internal/validation/validate.go b/go/internal/validation/validate.go new file mode 100644 index 000000000..ec5aeb8c7 --- /dev/null +++ b/go/internal/validation/validate.go @@ -0,0 +1,169 @@ +package validation + +import ( + "fmt" + "os" + "sort" + "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) { + fmt.Fprintf(os.Stderr, "ERROR: %s\n", msg) + hasError = true + } + + fmt.Println("\nValidating options and change files...") + + // Check for untracked changes + untracked, _ := git.GetUntrackedChanges(options.Path) + if len(untracked) > 0 { + fmt.Fprintf(os.Stderr, "WARN: 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 != "password" { + 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) + + changeTypeStr := entry.Change.Type.String() + if changeTypeStr == "" { + logError(fmt.Sprintf("Change type is missing in %s", entry.ChangeFile)) + } else if !IsValidChangeType(changeTypeStr) { + logError(fmt.Sprintf("Invalid change type detected in %s: %q", entry.ChangeFile, changeTypeStr)) + } else { + for _, d := range disallowed { + if changeTypeStr == d { + logError(fmt.Sprintf("Disallowed change type detected in %s: %q", entry.ChangeFile, changeTypeStr)) + break + } + } + } + + depTypeStr := entry.Change.DependentChangeType.String() + if depTypeStr == "" { + logError(fmt.Sprintf("dependentChangeType is missing in %s", entry.ChangeFile)) + } else if !IsValidDependentChangeType(depTypeStr, disallowed) { + logError(fmt.Sprintf("Invalid dependentChangeType detected in %s: %q", entry.ChangeFile, depTypeStr)) + } + } + + 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 := make([]string, len(changedPackages)) + copy(sorted, changedPackages) + sort.Strings(sorted) + fmt.Printf("%s:\n%s\n", msg, logging.BulletedList(sorted)) + } + + if result.IsChangeNeeded && !validateOpts.AllowMissingChangeFiles { + logError("Change files are needed!") + fmt.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") + } + } + + fmt.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..a49fc5f97 --- /dev/null +++ b/go/internal/validation/validate_test.go @@ -0,0 +1,76 @@ +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" +) + +const defaultBranch = "master" +const defaultRemoteBranch = "origin/master" + +func TestSucceedsWithNoChanges(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + repo.Checkout("-b", "test", defaultBranch) + + repoOpts := types.DefaultOptions() + repoOpts.Branch = defaultRemoteBranch + repoOpts.Fetch = false + + parsed := options.GetParsedOptionsForTest(repo.RootPath(), types.CliOptions{}, repoOpts) + result, err := validation.Validate(parsed, validation.ValidateOptions{ + CheckChangeNeeded: true, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.IsChangeNeeded { + t.Fatal("expected no change needed") + } +} + +func TestExitsWithErrorIfChangeFilesNeeded(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + repo.Checkout("-b", "test", defaultBranch) + repo.CommitChange("packages/foo/test.js") + + repoOpts := types.DefaultOptions() + repoOpts.Branch = defaultRemoteBranch + repoOpts.Fetch = false + + parsed := options.GetParsedOptionsForTest(repo.RootPath(), types.CliOptions{}, repoOpts) + _, err := validation.Validate(parsed, validation.ValidateOptions{ + CheckChangeNeeded: true, + }) + if err == nil { + t.Fatal("expected error but got nil") + } +} + +func TestReturnsWithoutErrorIfAllowMissingChangeFiles(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + repo.Checkout("-b", "test", defaultBranch) + repo.CommitChange("packages/foo/test.js") + + repoOpts := types.DefaultOptions() + repoOpts.Branch = defaultRemoteBranch + repoOpts.Fetch = false + + parsed := options.GetParsedOptionsForTest(repo.RootPath(), types.CliOptions{}, repoOpts) + result, err := validation.Validate(parsed, validation.ValidateOptions{ + CheckChangeNeeded: true, + AllowMissingChangeFiles: true, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.IsChangeNeeded { + t.Fatal("expected change needed") + } +} diff --git a/go/internal/validation/validators.go b/go/internal/validation/validators.go new file mode 100644 index 000000000..6272dd1fb --- /dev/null +++ b/go/internal/validation/validators.go @@ -0,0 +1,31 @@ +package validation + +import "github.com/microsoft/beachball/internal/types" + +var validAuthTypes = map[string]bool{ + "authToken": true, + "password": true, +} + +// IsValidAuthType checks if the auth type is valid. +func IsValidAuthType(authType string) bool { + return validAuthTypes[authType] +} + +// IsValidChangeType checks if a change type string is valid. +func IsValidChangeType(s string) bool { + return types.IsValidChangeType(s) +} + +// IsValidDependentChangeType checks if a dependent change type is valid. +func IsValidDependentChangeType(s string, disallowed []string) bool { + if !types.IsValidChangeType(s) { + return false + } + for _, d := range disallowed { + if s == d { + return false + } + } + return true +} diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 000000000..07de45da6 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,651 @@ +# 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", + "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 = "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 = "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 = "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..1e16bd875 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "beachball" +version = "0.1.0" +edition = "2021" + +[dependencies] +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +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..62905091c --- /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 +``` + +## 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 + +``` +beachball check [OPTIONS] +beachball change [OPTIONS] + +Options: + -b, --branch Target branch (default: origin/master) + -p, --path Path to the repository + -t, --type Change type: patch, minor, major, none, etc. + -m, --message Change description + --all Include all packages + --verbose Verbose output + --no-commit Don't commit change files + --no-fetch Don't fetch remote branch +``` + +## What's Implemented + +- CLI parsing (clap) +- JSON config loading (`.beachballrc.json`, `package.json` `"beachball"` field) +- Workspace detection (npm/yarn `workspaces` field) +- `getChangedPackages` (git diff + file-to-package mapping + change file dedup) +- `validate()` (minus `bumpInMemory`/dependency validation) +- Non-interactive `change` command (`--type` + `--message`) +- `check` command + +## What's Not Implemented + +- JS config files (`beachball.config.js`) +- Interactive prompts +- `bumpInMemory` / dependency validation +- `publish`, `bump`, `canary`, `sync` commands +- Changelog generation +- pnpm/rush/lerna workspace detection + +## Project Structure + +``` +src/ + main.rs # CLI entry point + lib.rs # Module re-exports + types/ # ChangeType, PackageInfo, BeachballOptions + 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) + changed_packages_test.rs # 11 tests + change_test.rs # 2 tests + validate_test.rs # 3 tests +``` diff --git a/rust/src/changefile/change_types.rs b/rust/src/changefile/change_types.rs new file mode 100644 index 000000000..f411f3fdb --- /dev/null +++ b/rust/src/changefile/change_types.rs @@ -0,0 +1,41 @@ +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()) { + if 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) { + if let Some(ref opts) = info.package_options { + if 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..ef1afad22 --- /dev/null +++ b/rust/src/changefile/changed_packages.rs @@ -0,0 +1,259 @@ +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::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; + let verbose = options.verbose; + + // If --all, return all in-scope non-private packages + if options.all { + if verbose { + eprintln!( + "--all option was provided, so including all packages that are in scope (regardless of changes)" + ); + } + let result: Vec = package_infos + .values() + .filter(|pkg| { + let (included, _reason) = is_package_included(Some(pkg), scoped_packages); + included + }) + .map(|pkg| pkg.name.clone()) + .collect(); + return Ok(result); + } + + println!("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); + + if verbose { + let count = changes.len(); + println!( + "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, verbose); + + if non_ignored.is_empty() { + if verbose { + eprintln!("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 { + if verbose { + eprintln!(" - ~~{file}~~ ({reason})"); + } + } else { + included_packages.insert(pkg_info.unwrap().name.clone()); + file_count += 1; + if verbose { + eprintln!(" - {file}"); + } + } + } + + if verbose { + let pkg_count = included_packages.len(); + println!( + "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(); + println!( + "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..f4283337e --- /dev/null +++ b/rust/src/changefile/read_change_files.rs @@ -0,0 +1,95 @@ +use std::path::Path; + +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::new(); + + 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) => { + eprintln!("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 { + eprintln!("WARN: Could not parse change file {filename}"); + continue; + }; + + for change in changes { + // Filter: package must exist, not be private, and be in scope + if !package_infos.contains_key(&change.package_name) { + continue; + } + let info = &package_infos[&change.package_name]; + if info.private { + 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..55f4a968a --- /dev/null +++ b/rust/src/changefile/write_change_files.rs @@ -0,0 +1,81 @@ +use anyhow::Result; +use std::path::Path; + +use crate::git::commands; +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::new(); + + 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])?; + } + } + + println!( + "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..640bce9d9 --- /dev/null +++ b/rust/src/commands/change.rs @@ -0,0 +1,79 @@ +use anyhow::{bail, Result}; + +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::types::change_info::{ChangeFileInfo, ChangeType}; +use crate::types::options::ParsedOptions; +use crate::validation::validate::{validate, ValidateOptions, ValidationResult}; + +/// 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() { + println!("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..40ea1fc95 --- /dev/null +++ b/rust/src/commands/check.rs @@ -0,0 +1,18 @@ +use anyhow::Result; + +use crate::types::options::ParsedOptions; +use crate::validation::validate::{validate, ValidateOptions}; + +/// 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() + }, + )?; + println!("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..14a6337c3 --- /dev/null +++ b/rust/src/git/commands.rs @@ -0,0 +1,286 @@ +use anyhow::{bail, Context, Result}; +use std::path::Path; +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) +} + +/// Find the project root (directory with package.json containing "workspaces", or git root). +pub fn find_project_root(cwd: &str) -> Result { + let git_root = find_git_root(cwd)?; + + // Walk from cwd up to git root looking for package.json with workspaces + let mut dir = Path::new(cwd).to_path_buf(); + let git_root_path = Path::new(&git_root); + + loop { + let pkg_json = dir.join("package.json"); + if pkg_json.exists() { + if let Ok(contents) = std::fs::read_to_string(&pkg_json) { + if let Ok(pkg) = + serde_json::from_str::(&contents) + { + if pkg.workspaces.is_some() { + return Ok(dir.to_string_lossy().to_string()); + } + } + } + } + + if dir == git_root_path { + break; + } + if !dir.pop() { + break; + } + } + + // Fall back to git root + Ok(git_root) +} + +/// 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", "--no-renames", &format!("{branch}...")], + cwd, + )?; + if !result.success { + return Ok(vec![]); + } + Ok(result + .stdout + .lines() + .filter(|l| !l.is_empty()) + .map(|l| l.to_string()) + .collect()) +} + +/// Get staged changes. +pub fn get_staged_changes(cwd: &str) -> Result> { + let result = git(&["--no-pager", "diff", "--cached", "--name-only", "--relative", "--no-renames"], cwd)?; + if !result.success { + return Ok(vec![]); + } + Ok(result + .stdout + .lines() + .filter(|l| !l.is_empty()) + .map(|l| l.to_string()) + .collect()) +} + +/// 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", "--no-renames"]; + 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(result + .stdout + .lines() + .filter(|l| !l.is_empty()) + .map(|l| l.to_string()) + .collect()) +} + +/// Get untracked files. +pub fn get_untracked_changes(cwd: &str) -> Result> { + let result = git(&["ls-files", "--others", "--exclude-standard"], cwd)?; + Ok(result + .stdout + .lines() + .filter(|l| !l.is_empty()) + .map(|l| l.to_string()) + .collect()) +} + +/// 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(), + )) +} + +/// Get the default remote branch (tries to detect from git remote). +pub fn get_default_remote_branch(cwd: &str) -> Result { + // Try to find the default remote + let remotes_output = git_stdout(&["remote"], cwd)?; + let remotes: Vec<&str> = remotes_output.lines().collect(); + + let remote = if remotes.contains(&"upstream") { + "upstream" + } else if remotes.contains(&"origin") { + "origin" + } else if let Some(first) = remotes.first() { + first + } else { + return Ok("origin/master".to_string()); + }; + + // Try to get the default branch from remote + let result = git(&["remote", "show", remote], cwd); + if let Ok(r) = result { + if r.success { + for line in r.stdout.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("HEAD branch:") { + let branch = trimmed.trim_start_matches("HEAD branch:").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")) +} + +/// 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(result + .stdout + .lines() + .filter(|l| !l.is_empty()) + .map(|l| l.to_string()) + .collect()) +} + +/// 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(), 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(()) +} diff --git a/rust/src/git/ensure_shared_history.rs b/rust/src/git/ensure_shared_history.rs new file mode 100644 index 000000000..f515b48cb --- /dev/null +++ b/rust/src/git/ensure_shared_history.rs @@ -0,0 +1,53 @@ +use anyhow::{bail, Result}; + +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 { + eprintln!("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 { + eprintln!("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..ff1e31e90 --- /dev/null +++ b/rust/src/logging.rs @@ -0,0 +1,4 @@ +/// 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..71970cc7e --- /dev/null +++ b/rust/src/monorepo/filter_ignored.rs @@ -0,0 +1,25 @@ +use super::path_included::match_with_base; + +/// 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], + verbose: bool, +) -> Vec { + file_paths + .iter() + .filter(|path| { + for pattern in ignore_patterns { + if match_with_base(path, pattern) { + if verbose { + eprintln!(" - ~~{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..cf6086ff9 --- /dev/null +++ b/rust/src/monorepo/mod.rs @@ -0,0 +1,5 @@ +pub mod filter_ignored; +pub mod package_groups; +pub mod package_infos; +pub mod path_included; +pub mod scoped_packages; diff --git a/rust/src/monorepo/package_groups.rs b/rust/src/monorepo/package_groups.rs new file mode 100644 index 000000000..4783cbd29 --- /dev/null +++ b/rust/src/monorepo/package_groups.rs @@ -0,0 +1,71 @@ +use anyhow::{bail, Result}; + +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::new(); + + 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 { + if !is_path_included(&rel_path, exclude) { + continue; + } + } + + // Check for multi-group membership + if let Some(existing_group) = package_to_group.get(&info.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..657f43cc7 --- /dev/null +++ b/rust/src/monorepo/package_infos.rs @@ -0,0 +1,98 @@ +use anyhow::{bail, Result}; +use std::path::{Path, PathBuf}; + +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 root_pkg_path = Path::new(cwd).join("package.json"); + + if !root_pkg_path.exists() { + bail!("No package.json found at {cwd}"); + } + + let root_pkg: PackageJson = + serde_json::from_str(&std::fs::read_to_string(&root_pkg_path)?)?; + + let mut infos = PackageInfos::new(); + + if let Some(ref workspaces) = root_pkg.workspaces { + // Monorepo: glob each workspace pattern + for ws_pattern in workspaces { + let full_pattern = Path::new(cwd).join(ws_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"); + if pkg_json_path.exists() { + if let Ok(info) = read_package_info(&pkg_json_path) { + if infos.contains_key(&info.name) { + 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); + } + } + } + } + } else { + // Single package repo + let info = read_package_info(&root_pkg_path)?; + let name = info.name.clone(); + infos.insert(name, info); + } + + // Apply package-level options from CLI if needed + apply_package_options(&mut infos, options); + + Ok(infos) +} + +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..712588a14 --- /dev/null +++ b/rust/src/monorepo/scoped_packages.rs @@ -0,0 +1,25 @@ +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/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..114224569 --- /dev/null +++ b/rust/src/options/get_options.rs @@ -0,0 +1,175 @@ +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 { + if !branch.contains('/') { + if let Ok(default) = commands::get_default_remote_branch(cwd) { + if let Some((remote, _)) = commands::parse_remote_branch(&default) { + merged.branch = format!("{remote}/{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..074cdf5da --- /dev/null +++ b/rust/src/options/repo_options.rs @@ -0,0 +1,195 @@ +use anyhow::Result; +use std::path::Path; + +use crate::git::commands::{find_git_root, get_default_remote_branch}; +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>, +} + +/// Search for and load repo-level beachball config. +/// Searches from `cwd` up to the git root for .beachballrc.json or package.json "beachball" field. +pub fn get_repo_options(cwd: &str, config_path: Option<&str>) -> Result { + let mut opts = BeachballOptions::default(); + + let raw = if let Some(path) = config_path { + load_json_config(path)? + } else { + search_for_config(cwd)? + }; + + if let Some(raw) = raw { + apply_raw_config(&mut opts, raw, cwd)?; + } + + Ok(opts) +} + +fn search_for_config(cwd: &str) -> Result> { + let git_root = find_git_root(cwd).unwrap_or_else(|_| cwd.to_string()); + let git_root_path = Path::new(&git_root); + let mut dir = Path::new(cwd).to_path_buf(); + + loop { + // Check for .beachballrc.json + let rc_path = dir.join(".beachballrc.json"); + if rc_path.exists() { + if let Ok(config) = load_json_config(rc_path.to_str().unwrap_or_default()) { + return Ok(config); + } + } + + // Check for package.json "beachball" field + let pkg_path = dir.join("package.json"); + if pkg_path.exists() { + if let Ok(Some(config)) = load_from_package_json(pkg_path.to_str().unwrap_or_default()) + { + return Ok(Some(config)); + } + } + + // Stop at git root + if dir == git_root_path { + break; + } + if !dir.pop() { + break; + } + } + + Ok(None) +} + +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 { + let default = get_default_remote_branch(cwd)?; + if let Some((remote, _)) = super::super::git::commands::parse_remote_branch(&default) { + opts.branch = format!("{remote}/{branch}"); + } else { + opts.branch = format!("origin/{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..b76a4fcf2 --- /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)] +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..632f019f6 --- /dev/null +++ b/rust/src/validation/are_change_files_deleted.rs @@ -0,0 +1,17 @@ +use crate::git::commands; +use crate::types::options::BeachballOptions; + +/// 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); + + let deleted = commands::get_changes_between_refs( + &options.branch, + Some("D"), + Some("*.json"), + &change_path, + ) + .unwrap_or_default(); + + !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..57da3b949 --- /dev/null +++ b/rust/src/validation/validate.rs @@ -0,0 +1,279 @@ +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::*; + +pub struct ValidateOptions { + pub check_change_needed: bool, + pub allow_missing_change_files: bool, + pub check_dependencies: bool, +} + +impl Default for ValidateOptions { + fn default() -> Self { + Self { + check_change_needed: false, + allow_missing_change_files: false, + check_dependencies: false, + } + } +} + +#[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) { + eprintln!("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; + + println!("\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() { + eprintln!( + "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 = Vec::new(); + 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 { + if !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 { + if !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 { + if !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 { + if !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 { + if 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(); + println!( + "{message}:\n{}", + bulleted_list(&sorted.iter().map(|s| s.as_str()).collect::>()) + ); + } + + if is_change_needed && !validate_options.allow_missing_change_files { + eprintln!("ERROR: Change files are needed!"); + println!("{}", options.changehint); + return Err(ValidationError { + message: "Change files are needed".to_string(), + } + .into()); + } + + if options.disallow_deleted_change_files && are_change_files_deleted(options) { + eprintln!("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() { + if options.verbose { + println!( + "(Skipping package dependency validation — not implemented in Rust port)" + ); + } + } + + println!(); + + 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..1a0919945 --- /dev/null +++ b/rust/src/validation/validators.rs @@ -0,0 +1,70 @@ +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 { + if 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() { + eprintln!("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) { + if let Some(ref opts) = info.package_options { + if opts.disallowed_change_types.is_some() { + eprintln!( + "ERROR: Package \"{pkg_name}\" has disallowedChangeTypes but is in a group. \ + Group-level disallowedChangeTypes take precedence." + ); + valid = false; + } + } + } + } + } + valid +} diff --git a/rust/tests/change_test.rs b/rust/tests/change_test.rs new file mode 100644 index 000000000..b47e4054b --- /dev/null +++ b/rust/tests/change_test.rs @@ -0,0 +1,72 @@ +mod common; + +use beachball::commands::change::change; +use beachball::options::get_options::get_parsed_options_for_test; +use beachball::types::change_info::{ChangeFileInfo, ChangeType}; +use beachball::types::options::{BeachballOptions, CliOptions}; +use common::change_files::get_change_files; +use common::repository_factory::RepositoryFactory; + +const DEFAULT_BRANCH: &str = "master"; +const DEFAULT_REMOTE_BRANCH: &str = "origin/master"; + +#[test] +fn does_not_create_change_files_when_no_changes() { + let factory = RepositoryFactory::new("single"); + let repo = factory.clone_repository(); + + let branch_name = "no-changes-test"; + repo.checkout(&["-b", branch_name, DEFAULT_BRANCH]); + + let mut repo_opts = BeachballOptions::default(); + repo_opts.branch = DEFAULT_REMOTE_BRANCH.to_string(); + repo_opts.fetch = false; + + let mut cli = CliOptions::default(); + cli.command = Some("change".to_string()); + cli.message = Some("test change".to_string()); + cli.change_type = Some(ChangeType::Patch); + + let parsed = get_parsed_options_for_test(repo.root_path(), cli, repo_opts); + let result = change(&parsed); + assert!(result.is_ok()); + + let files = get_change_files(&parsed.options); + assert!(files.is_empty()); +} + +#[test] +fn creates_change_file_with_type_and_message() { + let factory = RepositoryFactory::new("single"); + let repo = factory.clone_repository(); + + let branch_name = "creates-change-test"; + repo.checkout(&["-b", branch_name, DEFAULT_BRANCH]); + repo.commit_change("file.js"); + + let mut repo_opts = BeachballOptions::default(); + repo_opts.branch = DEFAULT_REMOTE_BRANCH.to_string(); + repo_opts.fetch = false; + repo_opts.commit = false; + + let mut cli = CliOptions::default(); + cli.command = Some("change".to_string()); + cli.message = Some("test description".to_string()); + cli.change_type = Some(ChangeType::Patch); + cli.commit = Some(false); + + let parsed = get_parsed_options_for_test(repo.root_path(), cli, repo_opts); + let result = change(&parsed); + assert!(result.is_ok()); + + let files = get_change_files(&parsed.options); + assert_eq!(files.len(), 1); + + // Verify the change file content + 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); +} diff --git a/rust/tests/changed_packages_test.rs b/rust/tests/changed_packages_test.rs new file mode 100644 index 000000000..5f834bba4 --- /dev/null +++ b/rust/tests/changed_packages_test.rs @@ -0,0 +1,268 @@ +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::Repository; +use common::repository_factory::RepositoryFactory; +use std::collections::HashMap; + +const DEFAULT_BRANCH: &str = "master"; +const DEFAULT_REMOTE_BRANCH: &str = "origin/master"; + +fn get_options_and_packages( + repo: &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: &Repository, name: &str) { + let branch_name = name.replace(|c: char| !c.is_alphanumeric(), "-"); + repo.checkout(&["-b", &branch_name, DEFAULT_BRANCH]); +} + +// ===== Basic tests ===== + +#[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()); +} + +#[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 (options, infos, scoped) = get_options_and_packages(&repo, None, None); + let result = get_changed_packages(&options, &infos, &scoped).unwrap(); + assert_eq!(result, vec!["foo"]); +} + +#[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()); +} + +#[test] +fn returns_given_package_names_as_is() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + + let mut cli = CliOptions::default(); + cli.package = Some(vec!["foo".to_string()]); + 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 mut cli2 = CliOptions::default(); + cli2.package = Some(vec!["foo".to_string(), "bar".to_string(), "nope".to_string()]); + 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"]); +} + +#[test] +fn returns_all_packages_with_all_true() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + + let mut opts = BeachballOptions::default(); + opts.all = true; + 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 ===== + +#[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"]); +} + +#[test] +fn respects_ignore_patterns() { + let factory = RepositoryFactory::new("single"); + let repo = factory.clone_repository(); + + let mut opts = BeachballOptions::default(); + opts.ignore_patterns = Some(vec![ + "*.test.js".to_string(), + "tests/**".to_string(), + "yarn.lock".to_string(), + ]); + opts.verbose = true; + + 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"]); + + let result = get_changed_packages(&options, &infos, &scoped).unwrap(); + assert!(result.is_empty()); +} + +// ===== Monorepo tests ===== + +#[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"]); +} + +#[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 mut opts = BeachballOptions::default(); + opts.verbose = true; + let (options, infos, scoped) = get_options_and_packages(&repo, Some(opts), None); + generate_change_files(&["foo"], &options, &repo); + + let result = get_changed_packages(&options, &infos, &scoped).unwrap(); + assert!(result.is_empty(), "Expected empty but got: {result:?}"); + + // 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"]); +} + +#[test] +fn ignores_package_changes_as_appropriate() { + use serde_json::json; + + let mut packages: HashMap = HashMap::new(); + packages.insert("private-pkg".to_string(), json!({ + "name": "private-pkg", "version": "1.0.0", "private": true + })); + packages.insert("no-publish".to_string(), json!({ + "name": "no-publish", "version": "1.0.0", + "beachball": { "shouldPublish": false } + })); + packages.insert("out-of-scope".to_string(), json!({ + "name": "out-of-scope", "version": "1.0.0" + })); + packages.insert("ignore-pkg".to_string(), json!({ + "name": "ignore-pkg", "version": "1.0.0" + })); + packages.insert("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 mut opts = BeachballOptions::default(); + opts.scope = Some(vec!["!packages/out-of-scope".to_string()]); + opts.ignore_patterns = Some(vec!["**/jest.config.js".to_string()]); + opts.verbose = true; + + let (options, infos, scoped) = get_options_and_packages(&repo, Some(opts), None); + let result = get_changed_packages(&options, &infos, &scoped).unwrap(); + assert_eq!(result, vec!["publish-me"]); +} + +#[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 mut opts_a = BeachballOptions::default(); + opts_a.path = path_a.clone(); + opts_a.branch = DEFAULT_REMOTE_BRANCH.to_string(); + opts_a.fetch = false; + + 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 mut opts_b = BeachballOptions::default(); + opts_b.path = path_b.clone(); + opts_b.branch = DEFAULT_REMOTE_BRANCH.to_string(); + opts_b.fetch = false; + + 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..f84d7c229 --- /dev/null +++ b/rust/tests/common/fixtures.rs @@ -0,0 +1,155 @@ +use serde_json::json; +use std::collections::HashMap; + +/// Package definition for a fixture. +pub struct PackageFixture { + pub name: Option, + pub version: String, + pub private: Option, + pub dependencies: Option>, + pub beachball: Option, + pub other: Option, +} + +impl PackageFixture { + pub fn to_json(&self, default_name: &str) -> serde_json::Value { + let mut obj = serde_json::Map::new(); + obj.insert( + "name".to_string(), + json!(self.name.as_deref().unwrap_or(default_name)), + ); + obj.insert("version".to_string(), json!(self.version)); + if let Some(private) = self.private { + obj.insert("private".to_string(), json!(private)); + } + if let Some(ref deps) = self.dependencies { + obj.insert("dependencies".to_string(), json!(deps)); + } + if let Some(ref bb) = self.beachball { + obj.insert("beachball".to_string(), bb.clone()); + } + if let Some(ref other) = self.other { + if let serde_json::Value::Object(map) = other { + for (k, v) in map { + obj.insert(k.clone(), v.clone()); + } + } + } + serde_json::Value::Object(obj) + } +} + +/// Fixture for a single-package repo. +pub fn single_package_fixture() -> (serde_json::Value, Vec<(String, HashMap)>) { + let root = json!({ + "name": "foo", + "version": "1.0.0", + "dependencies": { + "bar": "1.0.0", + "baz": "1.0.0" + } + }); + (root, vec![]) +} + +/// Fixture for a monorepo. +pub fn monorepo_fixture() -> (serde_json::Value, Vec<(String, HashMap)>) { + let root = json!({ + "name": "monorepo-fixture", + "version": "1.0.0", + "private": true, + "workspaces": ["packages/*", "packages/grouped/*"], + "beachball": { + "groups": [{ + "disallowedChangeTypes": null, + "name": "grouped", + "include": "group*" + }] + } + }); + + let packages: HashMap = HashMap::from([ + ("foo".to_string(), json!({ + "name": "foo", + "version": "1.0.0", + "dependencies": { "bar": "^1.3.4" }, + "main": "src/index.ts" + })), + ("bar".to_string(), json!({ + "name": "bar", + "version": "1.3.4", + "dependencies": { "baz": "^1.3.4" } + })), + ("baz".to_string(), json!({ + "name": "baz", + "version": "1.3.4" + })), + ]); + + let grouped: HashMap = HashMap::from([ + ("a".to_string(), json!({ + "name": "a", + "version": "3.1.2" + })), + ("b".to_string(), json!({ + "name": "b", + "version": "3.1.2" + })), + ]); + + (root, vec![ + ("packages".to_string(), packages), + ("packages/grouped".to_string(), grouped), + ]) +} + +/// Fixture for a monorepo with a scope prefix (used in multi-project). +pub fn scoped_monorepo_fixture(scope: &str) -> (serde_json::Value, Vec<(String, HashMap)>) { + let root = json!({ + "name": format!("@{scope}/monorepo-fixture"), + "version": "1.0.0", + "private": true, + "workspaces": ["packages/*", "packages/grouped/*"], + "beachball": { + "groups": [{ + "disallowedChangeTypes": null, + "name": "grouped", + "include": "group*" + }] + } + }); + + let packages: HashMap = HashMap::from([ + ("foo".to_string(), json!({ + "name": format!("@{scope}/foo"), + "version": "1.0.0", + "dependencies": { format!("@{scope}/bar"): "^1.3.4" }, + "main": "src/index.ts" + })), + ("bar".to_string(), json!({ + "name": format!("@{scope}/bar"), + "version": "1.3.4", + "dependencies": { format!("@{scope}/baz"): "^1.3.4" } + })), + ("baz".to_string(), json!({ + "name": format!("@{scope}/baz"), + "version": "1.3.4" + })), + ]); + + let grouped: HashMap = HashMap::from([ + ("a".to_string(), json!({ + "name": format!("@{scope}/a"), + "version": "3.1.2" + })), + ("b".to_string(), json!({ + "name": format!("@{scope}/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..5aa46f6ff --- /dev/null +++ b/rust/tests/common/mod.rs @@ -0,0 +1,4 @@ +pub mod change_files; +pub mod fixtures; +pub mod repository; +pub mod repository_factory; diff --git a/rust/tests/common/repository.rs b/rust/tests/common/repository.rs new file mode 100644 index 000000000..d1c45c676 --- /dev/null +++ b/rust/tests/common/repository.rs @@ -0,0 +1,131 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// 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.into_path(); + + // 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_ok() { + let _ = fs::remove_dir_all(&self.root); + } + } +} + +impl Drop for Repository { + fn drop(&mut self) { + self.clean_up(); + } +} + +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); + // Don't panic on expected failures + 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() +} diff --git a/rust/tests/common/repository_factory.rs b/rust/tests/common/repository_factory.rs new file mode 100644 index 000000000..4b51d8e22 --- /dev/null +++ b/rust/tests/common/repository_factory.rs @@ -0,0 +1,236 @@ +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +use super::fixtures; +use super::repository::Repository; + +/// 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.into_path(); + + // 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.into_path(); + + // 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_ok() { + 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: &PathBuf) { + run_git( + &["symbolic-ref", "HEAD", "refs/heads/master"], + bare_repo_path.to_str().unwrap(), + ); +} + +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() +} diff --git a/rust/tests/validate_test.rs b/rust/tests/validate_test.rs new file mode 100644 index 000000000..5349a150a --- /dev/null +++ b/rust/tests/validate_test.rs @@ -0,0 +1,73 @@ +mod common; + +use beachball::options::get_options::get_parsed_options_for_test; +use beachball::types::options::{BeachballOptions, CliOptions}; +use beachball::validation::validate::{validate, ValidateOptions, ValidationError}; +use common::repository::Repository; +use common::repository_factory::RepositoryFactory; + +const DEFAULT_REMOTE_BRANCH: &str = "origin/master"; + +fn make_test_options(cwd: &str) -> (CliOptions, BeachballOptions) { + let cli = CliOptions::default(); + let mut repo_opts = BeachballOptions::default(); + repo_opts.branch = DEFAULT_REMOTE_BRANCH.to_string(); + repo_opts.fetch = false; + (cli, repo_opts) +} + +fn validate_wrapper(repo: &Repository, validate_options: ValidateOptions) -> Result { + let (cli, repo_opts) = make_test_options(repo.root_path()); + let parsed = get_parsed_options_for_test(repo.root_path(), cli, repo_opts); + validate(&parsed, &validate_options) +} + +#[test] +fn succeeds_with_no_changes() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + repo.checkout(&["-b", "test"]); + + let result = validate_wrapper(&repo, ValidateOptions { + check_change_needed: true, + ..Default::default() + }); + + assert!(result.is_ok()); + let result = result.unwrap(); + assert!(!result.is_change_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"); + + let result = validate_wrapper(&repo, ValidateOptions { + check_change_needed: true, + ..Default::default() + }); + + let err = result.expect_err("expected validation to fail"); + assert!(err.downcast_ref::().is_some()); +} + +#[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"); + + let result = validate_wrapper(&repo, ValidateOptions { + check_change_needed: true, + allow_missing_change_files: true, + ..Default::default() + }); + + assert!(result.is_ok()); + let result = result.unwrap(); + assert!(result.is_change_needed); +} From 441166cbfbf523116becf0415737f39a6915ebe0 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Fri, 6 Mar 2026 10:40:25 -0800 Subject: [PATCH 02/38] more tests and some fixes --- go/internal/changefile/change_types_test.go | 124 +++++++++ .../changefile/changed_packages_test.go | 14 +- .../changefile/write_change_files_test.go | 184 ++++++++++++++ go/internal/monorepo/package_groups_test.go | 213 ++++++++++++++++ go/internal/monorepo/path_included_test.go | 63 +++++ .../are_change_files_deleted_test.go | 90 +++++++ rust/Cargo.toml | 2 +- rust/src/changefile/change_types.rs | 16 +- rust/src/git/commands.rs | 17 +- rust/src/monorepo/package_groups.rs | 5 +- rust/src/monorepo/package_infos.rs | 5 +- rust/src/options/get_options.rs | 11 +- rust/src/options/repo_options.rs | 10 +- rust/src/validation/validate.rs | 48 ++-- rust/src/validation/validators.rs | 13 +- rust/tests/are_change_files_deleted_test.rs | 86 +++++++ rust/tests/disallowed_change_types_test.rs | 126 ++++++++++ rust/tests/package_groups_test.rs | 238 ++++++++++++++++++ rust/tests/path_included_test.rs | 58 +++++ rust/tests/write_change_files_test.rs | 129 ++++++++++ 20 files changed, 1360 insertions(+), 92 deletions(-) create mode 100644 go/internal/changefile/change_types_test.go create mode 100644 go/internal/changefile/write_change_files_test.go create mode 100644 go/internal/monorepo/package_groups_test.go create mode 100644 go/internal/monorepo/path_included_test.go create mode 100644 go/internal/validation/are_change_files_deleted_test.go create mode 100644 rust/tests/are_change_files_deleted_test.rs create mode 100644 rust/tests/disallowed_change_types_test.rs create mode 100644 rust/tests/package_groups_test.rs create mode 100644 rust/tests/path_included_test.rs create mode 100644 rust/tests/write_change_files_test.rs diff --git a/go/internal/changefile/change_types_test.go b/go/internal/changefile/change_types_test.go new file mode 100644 index 000000000..c6822c215 --- /dev/null +++ b/go/internal/changefile/change_types_test.go @@ -0,0 +1,124 @@ +package changefile_test + +import ( + "testing" + + "github.com/microsoft/beachball/internal/changefile" + "github.com/microsoft/beachball/internal/types" +) + +func TestGetDisallowedChangeTypes_ReturnsNilForUnknownPackage(t *testing.T) { + infos := types.PackageInfos{} + groups := types.PackageGroups{} + opts := &types.BeachballOptions{} + + result := changefile.GetDisallowedChangeTypes("unknown-pkg", infos, groups, opts) + if result != nil { + t.Fatalf("expected nil, got: %v", result) + } +} + +func TestGetDisallowedChangeTypes_ReturnsNilWhenNoSettings(t *testing.T) { + infos := types.PackageInfos{ + "foo": &types.PackageInfo{ + Name: "foo", + Version: "1.0.0", + }, + } + groups := types.PackageGroups{} + opts := &types.BeachballOptions{} + + result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) + if result != nil { + t.Fatalf("expected nil, got: %v", result) + } +} + +func TestGetDisallowedChangeTypes_ReturnsPackageLevelDisallowedTypes(t *testing.T) { + infos := types.PackageInfos{ + "foo": &types.PackageInfo{ + Name: "foo", + Version: "1.0.0", + PackageOptions: &types.PackageOptions{ + DisallowedChangeTypes: []string{"major"}, + }, + }, + } + groups := types.PackageGroups{} + opts := &types.BeachballOptions{} + + result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) + if len(result) != 1 || result[0] != "major" { + t.Fatalf("expected [major], got: %v", result) + } +} + +func TestGetDisallowedChangeTypes_ReturnsGroupLevelDisallowedTypes(t *testing.T) { + infos := types.PackageInfos{ + "foo": &types.PackageInfo{ + Name: "foo", + Version: "1.0.0", + }, + } + groups := types.PackageGroups{ + "grp1": &types.PackageGroup{ + Name: "grp1", + Packages: []string{"foo"}, + DisallowedChangeTypes: []string{"major", "minor"}, + }, + } + opts := &types.BeachballOptions{} + + result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) + if len(result) != 2 || result[0] != "major" || result[1] != "minor" { + t.Fatalf("expected [major minor], got: %v", result) + } +} + +func TestGetDisallowedChangeTypes_ReturnsNilIfNotInGroup(t *testing.T) { + infos := types.PackageInfos{ + "bar": &types.PackageInfo{ + Name: "bar", + Version: "1.0.0", + }, + } + groups := types.PackageGroups{ + "grp1": &types.PackageGroup{ + Name: "grp1", + Packages: []string{"foo"}, + DisallowedChangeTypes: []string{"major"}, + }, + } + opts := &types.BeachballOptions{} + + result := changefile.GetDisallowedChangeTypes("bar", infos, groups, opts) + if result != nil { + t.Fatalf("expected nil, got: %v", result) + } +} + +func TestGetDisallowedChangeTypes_PrefersPackageOverGroup(t *testing.T) { + infos := types.PackageInfos{ + "foo": &types.PackageInfo{ + Name: "foo", + Version: "1.0.0", + PackageOptions: &types.PackageOptions{ + DisallowedChangeTypes: []string{"major"}, + }, + }, + } + groups := types.PackageGroups{ + "grp1": &types.PackageGroup{ + Name: "grp1", + Packages: []string{"foo"}, + DisallowedChangeTypes: []string{"minor"}, + }, + } + opts := &types.BeachballOptions{} + + // The implementation checks package-level first, so it should return "major" + result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) + if len(result) != 1 || result[0] != "major" { + t.Fatalf("expected [major], got: %v", result) + } +} diff --git a/go/internal/changefile/changed_packages_test.go b/go/internal/changefile/changed_packages_test.go index f2f49d54c..799d1fe68 100644 --- a/go/internal/changefile/changed_packages_test.go +++ b/go/internal/changefile/changed_packages_test.go @@ -39,19 +39,7 @@ func getOptionsAndPackages(t *testing.T, repo *testutil.Repository, overrides *t } func checkOutTestBranch(repo *testutil.Repository, name string) { - repo.Checkout("-b", sanitizeBranchName(name), defaultBranch) -} - -func sanitizeBranchName(name string) string { - result := make([]byte, 0, len(name)) - for _, c := range name { - if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') { - result = append(result, byte(c)) - } else { - result = append(result, '-') - } - } - return string(result) + repo.Checkout("-b", name, defaultBranch) } // ===== Basic tests ===== 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..fc06bc523 --- /dev/null +++ b/go/internal/changefile/write_change_files_test.go @@ -0,0 +1,184 @@ +package changefile_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/microsoft/beachball/internal/changefile" + "github.com/microsoft/beachball/internal/testutil" + "github.com/microsoft/beachball/internal/types" +) + +func TestWriteChangeFiles_WritesIndividualChangeFiles(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + repo.Checkout("-b", "write-test", defaultBranch) + + opts := types.DefaultOptions() + opts.Path = repo.RootPath() + opts.Branch = defaultRemoteBranch + opts.Fetch = false + + changes := []types.ChangeFileInfo{ + { + Type: types.ChangeTypePatch, + Comment: "fix foo", + PackageName: "foo", + Email: "test@test.com", + DependentChangeType: types.ChangeTypePatch, + }, + { + Type: types.ChangeTypeMinor, + Comment: "add bar feature", + PackageName: "bar", + Email: "test@test.com", + DependentChangeType: types.ChangeTypePatch, + }, + } + + err := changefile.WriteChangeFiles(&opts, changes) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + files := testutil.GetChangeFiles(&opts) + if len(files) != 2 { + t.Fatalf("expected 2 change files, got %d", len(files)) + } + + // Verify file contents + foundFoo := false + foundBar := false + for _, f := range files { + data, err := os.ReadFile(f) + if err != nil { + t.Fatalf("failed to read %s: %v", f, err) + } + var change types.ChangeFileInfo + if err := json.Unmarshal(data, &change); err != nil { + t.Fatalf("failed to parse %s: %v", f, err) + } + switch change.PackageName { + case "foo": + foundFoo = true + if change.Type != types.ChangeTypePatch { + t.Fatalf("expected patch for foo, got %s", change.Type) + } + if change.Comment != "fix foo" { + t.Fatalf("expected 'fix foo', got %q", change.Comment) + } + case "bar": + foundBar = true + if change.Type != types.ChangeTypeMinor { + t.Fatalf("expected minor for bar, got %s", change.Type) + } + if change.Comment != "add bar feature" { + t.Fatalf("expected 'add bar feature', got %q", change.Comment) + } + default: + t.Fatalf("unexpected package: %s", change.PackageName) + } + } + if !foundFoo || !foundBar { + t.Fatalf("expected both foo and bar change files, foundFoo=%v foundBar=%v", foundFoo, foundBar) + } + + // Verify files are committed (default Commit=true) + status := repo.Status() + if status != "" { + t.Fatalf("expected clean working tree after commit, got: %s", status) + } +} + +func TestWriteChangeFiles_RespectsChangeDirOption(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + repo.Checkout("-b", "custom-dir-test", defaultBranch) + + opts := types.DefaultOptions() + opts.Path = repo.RootPath() + opts.Branch = defaultRemoteBranch + opts.Fetch = false + opts.ChangeDir = "my-changes" + + changes := []types.ChangeFileInfo{ + { + Type: types.ChangeTypePatch, + Comment: "test change", + PackageName: "foo", + Email: "test@test.com", + DependentChangeType: types.ChangeTypePatch, + }, + } + + err := changefile.WriteChangeFiles(&opts, changes) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify the custom directory was used + customPath := filepath.Join(repo.RootPath(), "my-changes") + entries, err := os.ReadDir(customPath) + if err != nil { + t.Fatalf("failed to read custom change dir: %v", err) + } + jsonCount := 0 + for _, e := range entries { + if filepath.Ext(e.Name()) == ".json" { + jsonCount++ + } + } + if jsonCount != 1 { + t.Fatalf("expected 1 json file in my-changes, got %d", jsonCount) + } + + // Default change dir should not exist + defaultPath := filepath.Join(repo.RootPath(), "change") + if _, err := os.Stat(defaultPath); err == nil { + t.Fatal("expected default change dir to not exist") + } +} + +func TestWriteChangeFiles_RespectsCommitFalse(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + repo.Checkout("-b", "no-commit-test", defaultBranch) + + // Get the current HEAD hash before writing + headBefore := repo.Git([]string{"rev-parse", "HEAD"}) + + opts := types.DefaultOptions() + opts.Path = repo.RootPath() + opts.Branch = defaultRemoteBranch + opts.Fetch = false + opts.Commit = false + + changes := []types.ChangeFileInfo{ + { + Type: types.ChangeTypePatch, + Comment: "uncommitted change", + PackageName: "foo", + Email: "test@test.com", + DependentChangeType: types.ChangeTypePatch, + }, + } + + err := changefile.WriteChangeFiles(&opts, changes) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify files exist + files := testutil.GetChangeFiles(&opts) + if len(files) != 1 { + t.Fatalf("expected 1 change file, got %d", len(files)) + } + + // Verify HEAD hash is unchanged (no commit was made) + headAfter := repo.Git([]string{"rev-parse", "HEAD"}) + if headBefore != headAfter { + t.Fatalf("expected HEAD to be unchanged, before=%s after=%s", headBefore, headAfter) + } +} diff --git a/go/internal/monorepo/package_groups_test.go b/go/internal/monorepo/package_groups_test.go new file mode 100644 index 000000000..12a0f2f4f --- /dev/null +++ b/go/internal/monorepo/package_groups_test.go @@ -0,0 +1,213 @@ +package monorepo_test + +import ( + "path/filepath" + "sort" + "testing" + + "github.com/microsoft/beachball/internal/monorepo" + "github.com/microsoft/beachball/internal/types" +) + +func makeInfos(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 +} + +func TestGetPackageGroups_ReturnsEmptyIfNoGroups(t *testing.T) { + infos := makeInfos("/repo", map[string]string{ + "packages/foo": "foo", + }) + result := monorepo.GetPackageGroups(infos, "/repo", nil) + if len(result) != 0 { + t.Fatalf("expected empty map, got: %v", result) + } +} + +func TestGetPackageGroups_ReturnsGroupsBasedOnSpecificFolders(t *testing.T) { + infos := makeInfos("/repo", 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, "/repo", groups) + if len(result) != 1 { + t.Fatalf("expected 1 group, got %d", len(result)) + } + grp := result["grp1"] + if grp == nil { + t.Fatal("expected grp1 to exist") + } + sort.Strings(grp.Packages) + if len(grp.Packages) != 2 { + t.Fatalf("expected 2 packages, got %d: %v", len(grp.Packages), grp.Packages) + } + if grp.Packages[0] != "bar" || grp.Packages[1] != "foo" { + t.Fatalf("expected [bar foo], got: %v", grp.Packages) + } +} + +func TestGetPackageGroups_HandlesSingleLevelGlobs(t *testing.T) { + infos := makeInfos("/repo", 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, "/repo", groups) + grp := result["ui"] + if grp == nil { + t.Fatal("expected ui group to exist") + } + sort.Strings(grp.Packages) + if len(grp.Packages) != 2 { + t.Fatalf("expected 2 packages, got %d: %v", len(grp.Packages), grp.Packages) + } + if grp.Packages[0] != "ui-button" || grp.Packages[1] != "ui-input" { + t.Fatalf("expected [ui-button ui-input], got: %v", grp.Packages) + } +} + +func TestGetPackageGroups_HandlesMultiLevelGlobs(t *testing.T) { + infos := makeInfos("/repo", 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, "/repo", groups) + grp := result["ui"] + if grp == nil { + t.Fatal("expected ui group to exist") + } + sort.Strings(grp.Packages) + if len(grp.Packages) != 2 { + t.Fatalf("expected 2 packages, got %d: %v", len(grp.Packages), grp.Packages) + } + if grp.Packages[0] != "ui-button" || grp.Packages[1] != "ui-input" { + t.Fatalf("expected [ui-button ui-input], got: %v", grp.Packages) + } +} + +func TestGetPackageGroups_HandlesMultipleIncludePatterns(t *testing.T) { + infos := makeInfos("/repo", 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, "/repo", groups) + grp := result["mixed"] + if grp == nil { + t.Fatal("expected mixed group to exist") + } + sort.Strings(grp.Packages) + if len(grp.Packages) != 2 { + t.Fatalf("expected 2 packages, got %d: %v", len(grp.Packages), grp.Packages) + } + if grp.Packages[0] != "bar" || grp.Packages[1] != "foo" { + t.Fatalf("expected [bar foo], got: %v", grp.Packages) + } +} + +func TestGetPackageGroups_HandlesExcludePatterns(t *testing.T) { + infos := makeInfos("/repo", 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, "/repo", groups) + grp := result["public"] + if grp == nil { + t.Fatal("expected public group to exist") + } + sort.Strings(grp.Packages) + if len(grp.Packages) != 2 { + t.Fatalf("expected 2 packages, got %d: %v", len(grp.Packages), grp.Packages) + } + if grp.Packages[0] != "bar" || grp.Packages[1] != "foo" { + t.Fatalf("expected [bar foo], got: %v", grp.Packages) + } +} + +func TestGetPackageGroups_HandlesGlobExclude(t *testing.T) { + infos := makeInfos("/repo", 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, "/repo", groups) + grp := result["non-core"] + if grp == nil { + t.Fatal("expected non-core group to exist") + } + sort.Strings(grp.Packages) + if len(grp.Packages) != 2 { + t.Fatalf("expected 2 packages, got %d: %v", len(grp.Packages), grp.Packages) + } + if grp.Packages[0] != "ui-button" || grp.Packages[1] != "ui-input" { + t.Fatalf("expected [ui-button ui-input], got: %v", grp.Packages) + } +} + +func TestGetPackageGroups_OmitsEmptyGroups(t *testing.T) { + infos := makeInfos("/repo", map[string]string{ + "packages/foo": "foo", + }) + groups := []types.VersionGroupOptions{ + { + Name: "empty", + Include: []string{"nonexistent/*"}, + }, + } + result := monorepo.GetPackageGroups(infos, "/repo", groups) + grp := result["empty"] + if grp == nil { + t.Fatal("expected empty group key to exist") + } + if len(grp.Packages) != 0 { + t.Fatalf("expected 0 packages, got %d: %v", len(grp.Packages), grp.Packages) + } +} diff --git a/go/internal/monorepo/path_included_test.go b/go/internal/monorepo/path_included_test.go new file mode 100644 index 000000000..924d53bc2 --- /dev/null +++ b/go/internal/monorepo/path_included_test.go @@ -0,0 +1,63 @@ +package monorepo_test + +import ( + "testing" + + "github.com/microsoft/beachball/internal/monorepo" +) + +func TestFilterIgnoredFiles_MatchesBasenamePatterns(t *testing.T) { + result := monorepo.FilterIgnoredFiles([]string{"src/foo.test.js"}, []string{"*.test.js"}, false) + if len(result) != 0 { + t.Fatalf("expected empty result, got: %v", result) + } +} + +func TestFilterIgnoredFiles_MatchesPathPatterns(t *testing.T) { + result := monorepo.FilterIgnoredFiles([]string{"tests/stuff.js"}, []string{"tests/**"}, false) + if len(result) != 0 { + t.Fatalf("expected empty result, got: %v", result) + } +} + +func TestFilterIgnoredFiles_DoesNotMatchUnrelatedFiles(t *testing.T) { + result := monorepo.FilterIgnoredFiles([]string{"src/index.js"}, []string{"*.test.js"}, false) + if len(result) != 1 || result[0] != "src/index.js" { + t.Fatalf("expected [src/index.js], got: %v", result) + } +} + +func TestFilterIgnoredFiles_MatchesChangeDirPattern(t *testing.T) { + result := monorepo.FilterIgnoredFiles([]string{"change/foo.json"}, []string{"change/*.json"}, false) + if len(result) != 0 { + t.Fatalf("expected empty result, got: %v", result) + } +} + +func TestFilterIgnoredFiles_MatchesCHANGELOG(t *testing.T) { + result := monorepo.FilterIgnoredFiles([]string{"packages/foo/CHANGELOG.md"}, []string{"CHANGELOG.md"}, false) + if len(result) != 0 { + t.Fatalf("expected empty result, got: %v", 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, false) + if len(result) != 1 || result[0] != "src/index.js" { + t.Fatalf("expected [src/index.js], got: %v", 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, false) + if len(result) != 2 { + t.Fatalf("expected 2 results, got: %v", result) + } + if result[0] != "src/index.js" || result[1] != "lib/utils.js" { + t.Fatalf("expected [src/index.js lib/utils.js], got: %v", result) + } +} 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..cd1897793 --- /dev/null +++ b/go/internal/validation/are_change_files_deleted_test.go @@ -0,0 +1,90 @@ +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" +) + +func TestAreChangeFilesDeleted_FalseWhenNoChangeFilesDeleted(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + + // Create a change file on master and push it + opts := types.DefaultOptions() + opts.Path = repo.RootPath() + opts.Branch = defaultRemoteBranch + opts.Fetch = false + + testutil.GenerateChangeFiles(t, []string{"foo"}, &opts, repo) + repo.Push() + + // Checkout a new branch — no deletions + repo.Checkout("-b", "test-no-delete", defaultBranch) + + result := validation.AreChangeFilesDeleted(&opts) + if result { + t.Fatal("expected false when no 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 := types.DefaultOptions() + opts.Path = repo.RootPath() + opts.Branch = defaultRemoteBranch + opts.Fetch = false + + 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", 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) + if !result { + t.Fatal("expected true when change files are deleted") + } +} + +func TestAreChangeFilesDeleted_WorksWithCustomChangeDir(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + + opts := types.DefaultOptions() + opts.Path = repo.RootPath() + opts.Branch = defaultRemoteBranch + opts.Fetch = false + opts.ChangeDir = "custom-changes" + + testutil.GenerateChangeFiles(t, []string{"foo"}, &opts, repo) + repo.Push() + + repo.Checkout("-b", "test-custom-delete", 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) + if !result { + t.Fatal("expected true when custom change files are deleted") + } +} diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 1e16bd875..dfb02b62a 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "beachball" version = "0.1.0" -edition = "2021" +edition = "2024" [dependencies] clap = { version = "4", features = ["derive"] } diff --git a/rust/src/changefile/change_types.rs b/rust/src/changefile/change_types.rs index f411f3fdb..a41abe0b6 100644 --- a/rust/src/changefile/change_types.rs +++ b/rust/src/changefile/change_types.rs @@ -20,21 +20,19 @@ pub fn get_disallowed_change_types( ) -> 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()) { - if group_info.disallowed_change_types.is_some() { - return group_info.disallowed_change_types.clone(); - } + 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) { - if let Some(ref opts) = info.package_options { - if opts.disallowed_change_types.is_some() { + 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/git/commands.rs b/rust/src/git/commands.rs index 14a6337c3..f46e2407f 100644 --- a/rust/src/git/commands.rs +++ b/rust/src/git/commands.rs @@ -56,17 +56,13 @@ pub fn find_project_root(cwd: &str) -> Result { loop { let pkg_json = dir.join("package.json"); - if pkg_json.exists() { - if let Ok(contents) = std::fs::read_to_string(&pkg_json) { - if let Ok(pkg) = + if pkg_json.exists() + && let Ok(contents) = std::fs::read_to_string(&pkg_json) + && let Ok(pkg) = serde_json::from_str::(&contents) - { - if pkg.workspaces.is_some() { + && pkg.workspaces.is_some() { return Ok(dir.to_string_lossy().to_string()); } - } - } - } if dir == git_root_path { break; @@ -235,8 +231,8 @@ pub fn get_default_remote_branch(cwd: &str) -> Result { // Try to get the default branch from remote let result = git(&["remote", "show", remote], cwd); - if let Ok(r) = result { - if r.success { + if let Ok(r) = result + && r.success { for line in r.stdout.lines() { let trimmed = line.trim(); if trimmed.starts_with("HEAD branch:") { @@ -245,7 +241,6 @@ pub fn get_default_remote_branch(cwd: &str) -> Result { } } } - } // Fallback: try git config init.defaultBranch if let Ok(default_branch) = git_stdout(&["config", "init.defaultBranch"], cwd) { diff --git a/rust/src/monorepo/package_groups.rs b/rust/src/monorepo/package_groups.rs index 4783cbd29..c75191dc5 100644 --- a/rust/src/monorepo/package_groups.rs +++ b/rust/src/monorepo/package_groups.rs @@ -38,11 +38,10 @@ pub fn get_package_groups( } // Check exclude patterns - if let Some(ref exclude) = group.exclude { - if !is_path_included(&rel_path, exclude) { + 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) { diff --git a/rust/src/monorepo/package_infos.rs b/rust/src/monorepo/package_infos.rs index 657f43cc7..dab0e4785 100644 --- a/rust/src/monorepo/package_infos.rs +++ b/rust/src/monorepo/package_infos.rs @@ -29,8 +29,8 @@ pub fn get_package_infos(options: &BeachballOptions) -> Result { for entry in entries.flatten() { let pkg_json_path = entry.join("package.json"); - if pkg_json_path.exists() { - if let Ok(info) = read_package_info(&pkg_json_path) { + if pkg_json_path.exists() + && let Ok(info) = read_package_info(&pkg_json_path) { if infos.contains_key(&info.name) { bail!( "Duplicate package name \"{}\" found at {} and {}", @@ -42,7 +42,6 @@ pub fn get_package_infos(options: &BeachballOptions) -> Result { let name = info.name.clone(); infos.insert(name, info); } - } } } } else { diff --git a/rust/src/options/get_options.rs b/rust/src/options/get_options.rs index 114224569..dd31679cd 100644 --- a/rust/src/options/get_options.rs +++ b/rust/src/options/get_options.rs @@ -17,15 +17,12 @@ pub fn get_parsed_options(cwd: &str, cli: CliOptions) -> Result { merged.path = cwd.to_string(); // If branch doesn't contain '/', resolve the remote - if let Some(ref branch) = cli.branch { - if !branch.contains('/') { - if let Ok(default) = commands::get_default_remote_branch(cwd) { - if let Some((remote, _)) = commands::parse_remote_branch(&default) { + if let Some(ref branch) = cli.branch + && !branch.contains('/') + && let Ok(default) = commands::get_default_remote_branch(cwd) + && let Some((remote, _)) = commands::parse_remote_branch(&default) { merged.branch = format!("{remote}/{branch}"); } - } - } - } Ok(ParsedOptions { cli_options: cli, diff --git a/rust/src/options/repo_options.rs b/rust/src/options/repo_options.rs index 074cdf5da..2ab145e42 100644 --- a/rust/src/options/repo_options.rs +++ b/rust/src/options/repo_options.rs @@ -60,20 +60,18 @@ fn search_for_config(cwd: &str) -> Result> { loop { // Check for .beachballrc.json let rc_path = dir.join(".beachballrc.json"); - if rc_path.exists() { - if let Ok(config) = load_json_config(rc_path.to_str().unwrap_or_default()) { + if rc_path.exists() + && let Ok(config) = load_json_config(rc_path.to_str().unwrap_or_default()) { return Ok(config); } - } // Check for package.json "beachball" field let pkg_path = dir.join("package.json"); - if pkg_path.exists() { - if let Ok(Some(config)) = load_from_package_json(pkg_path.to_str().unwrap_or_default()) + if pkg_path.exists() + && let Ok(Some(config)) = load_from_package_json(pkg_path.to_str().unwrap_or_default()) { return Ok(Some(config)); } - } // Stop at git root if dir == git_root_path { diff --git a/rust/src/validation/validate.rs b/rust/src/validation/validate.rs index 57da3b949..8e4b94094 100644 --- a/rust/src/validation/validate.rs +++ b/rust/src/validation/validate.rs @@ -14,22 +14,13 @@ use crate::types::package_info::{PackageGroups, PackageInfos, ScopedPackages}; use crate::validation::are_change_files_deleted::are_change_files_deleted; use crate::validation::validators::*; +#[derive(Default)] pub struct ValidateOptions { pub check_change_needed: bool, pub allow_missing_change_files: bool, pub check_dependencies: bool, } -impl Default for ValidateOptions { - fn default() -> Self { - Self { - check_change_needed: false, - allow_missing_change_files: false, - check_dependencies: false, - } - } -} - #[derive(Debug)] pub struct ValidationResult { pub is_change_needed: bool, @@ -115,41 +106,37 @@ pub fn validate( } // Validate auth type - if let Some(ref auth_type) = options.auth_type { - if !is_valid_auth_type(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 { - if !is_valid_change_type_value(dct) { + 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 { - if !is_valid_change_type_value(ct) { + 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 { - if !is_valid_group_options(groups) { + 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)?; @@ -182,8 +169,8 @@ pub fn validate( ), &mut has_error, ); - } else if let Some(ref disallowed) = disallowed { - if disallowed.contains(&entry.change.change_type) { + } else if let Some(ref disallowed) = disallowed + && disallowed.contains(&entry.change.change_type) { log_validation_error( &format!( "Disallowed change type detected in {}: \"{}\"", @@ -192,7 +179,6 @@ pub fn validate( &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) { @@ -258,12 +244,12 @@ pub fn validate( } // Skip checkDependencies / bumpInMemory (not implemented) - if validate_options.check_dependencies && !is_change_needed && !change_set.is_empty() { - if options.verbose { - println!( - "(Skipping package dependency validation — not implemented in Rust port)" - ); - } + if validate_options.check_dependencies && !is_change_needed && !change_set.is_empty() + && options.verbose + { + println!( + "(Skipping package dependency validation — not implemented in Rust port)" + ); } println!(); diff --git a/rust/src/validation/validators.rs b/rust/src/validation/validators.rs index 1a0919945..3fb0c07f9 100644 --- a/rust/src/validation/validators.rs +++ b/rust/src/validation/validators.rs @@ -25,11 +25,10 @@ pub fn is_valid_dependent_change_type( if !is_valid_change_type_value(ct) { return false; } - if let Some(disallowed) = disallowed { - if disallowed.contains(&ct) { + if let Some(disallowed) = disallowed + && disallowed.contains(&ct) { return false; } - } true } @@ -53,17 +52,15 @@ pub fn is_valid_grouped_package_options( 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) { - if let Some(ref opts) = info.package_options { - if opts.disallowed_change_types.is_some() { + if let Some(info) = package_infos.get(pkg_name) + && let Some(ref opts) = info.package_options + && opts.disallowed_change_types.is_some() { eprintln!( "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..37c62234c --- /dev/null +++ b/rust/tests/are_change_files_deleted_test.rs @@ -0,0 +1,86 @@ +mod common; + +use beachball::options::get_options::get_parsed_options_for_test; +use beachball::types::options::{BeachballOptions, CliOptions}; +use beachball::validation::are_change_files_deleted::are_change_files_deleted; +use common::change_files::generate_change_files; +use common::repository_factory::RepositoryFactory; + +const DEFAULT_BRANCH: &str = "master"; +const DEFAULT_REMOTE_BRANCH: &str = "origin/master"; + +fn make_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 +} + +#[test] +fn is_false_when_no_change_files_are_deleted() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + + let options = make_options(repo.root_path(), None); + generate_change_files(&["foo"], &options, &repo); + repo.push(); + + repo.checkout(&["-b", "test", DEFAULT_BRANCH]); + + let options = make_options(repo.root_path(), None); + assert!(!are_change_files_deleted(&options)); +} + +#[test] +fn is_true_when_change_files_are_deleted() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + + let options = make_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"]); + + let options = make_options(repo.root_path(), None); + assert!(are_change_files_deleted(&options)); +} + +#[test] +fn works_with_custom_change_dir() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + + let mut custom_opts = BeachballOptions::default(); + custom_opts.change_dir = "changeDir".to_string(); + + let options = make_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_options(repo.root_path(), Some(custom_opts)); + assert!(are_change_files_deleted(&options)); +} diff --git a/rust/tests/disallowed_change_types_test.rs b/rust/tests/disallowed_change_types_test.rs new file mode 100644 index 000000000..86eb8a9ae --- /dev/null +++ b/rust/tests/disallowed_change_types_test.rs @@ -0,0 +1,126 @@ +use beachball::changefile::change_types::get_disallowed_change_types; +use beachball::types::change_info::ChangeType; +use beachball::types::package_info::{ + PackageGroupInfo, PackageGroups, PackageInfo, PackageInfos, PackageOptions, +}; + +fn make_info(name: &str) -> PackageInfo { + PackageInfo { + name: name.to_string(), + package_json_path: format!("/fake/{name}/package.json"), + version: "1.0.0".to_string(), + private: false, + package_options: None, + dependencies: None, + dev_dependencies: None, + peer_dependencies: None, + optional_dependencies: None, + } +} + +#[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); +} + +#[test] +fn falls_back_to_repo_option() { + let mut infos = PackageInfos::new(); + infos.insert("foo".to_string(), make_info("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])); +} + +#[test] +fn returns_package_level_disallowed() { + let mut infos = PackageInfos::new(); + let mut info = make_info("foo"); + info.package_options = Some(PackageOptions { + disallowed_change_types: Some(vec![ChangeType::Major, ChangeType::Minor]), + tag: None, + default_npm_tag: None, + git_tags: None, + should_publish: None, + }); + infos.insert("foo".to_string(), info); + let groups = PackageGroups::new(); + + let result = get_disallowed_change_types("foo", &infos, &groups, &None); + assert_eq!(result, Some(vec![ChangeType::Major, ChangeType::Minor])); +} + +#[test] +fn returns_group_level_disallowed() { + let mut infos = PackageInfos::new(); + infos.insert("foo".to_string(), make_info("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])); +} + +#[test] +fn returns_package_level_if_not_in_group() { + let mut infos = PackageInfos::new(); + let mut info = make_info("foo"); + info.package_options = Some(PackageOptions { + disallowed_change_types: Some(vec![ChangeType::Minor]), + tag: None, + default_npm_tag: None, + git_tags: None, + should_publish: None, + }); + infos.insert("foo".to_string(), info); + + 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])); +} + +#[test] +fn prefers_group_over_package() { + let mut infos = PackageInfos::new(); + let mut info = make_info("foo"); + info.package_options = Some(PackageOptions { + disallowed_change_types: Some(vec![ChangeType::Minor]), + tag: None, + default_npm_tag: None, + git_tags: None, + should_publish: None, + }); + infos.insert("foo".to_string(), info); + + 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/package_groups_test.rs b/rust/tests/package_groups_test.rs new file mode 100644 index 000000000..215ac9835 --- /dev/null +++ b/rust/tests/package_groups_test.rs @@ -0,0 +1,238 @@ +use beachball::monorepo::package_groups::get_package_groups; +use beachball::types::options::{VersionGroupInclude, VersionGroupOptions}; +use beachball::types::package_info::{PackageInfo, PackageInfos}; + +fn make_info(name: &str, root: &str, folder: &str) -> PackageInfo { + PackageInfo { + name: name.to_string(), + package_json_path: format!("{}/{}/package.json", root, folder), + version: "1.0.0".to_string(), + private: false, + package_options: None, + dependencies: None, + dev_dependencies: None, + peer_dependencies: None, + optional_dependencies: None, + } +} + +fn make_infos(packages: &[(&str, &str)], root: &str) -> PackageInfos { + let mut infos = PackageInfos::new(); + for (name, folder) in packages { + infos.insert(name.to_string(), make_info(name, root, folder)); + } + infos +} + +const ROOT: &str = "/fake-root"; + +#[test] +fn returns_empty_if_no_groups_defined() { + let infos = make_infos(&[("foo", "packages/foo")], ROOT); + let result = get_package_groups(&infos, ROOT, &None).unwrap(); + assert!(result.is_empty()); +} + +#[test] +fn returns_groups_based_on_specific_folders() { + let infos = make_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"]); +} + +#[test] +fn handles_single_level_globs() { + let infos = make_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"]); +} + +#[test] +fn handles_multi_level_globs() { + let infos = make_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"]); +} + +#[test] +fn handles_multiple_include_patterns() { + let infos = make_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"]); +} + +#[test] +fn handles_specific_exclude_patterns() { + let infos = make_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"]); +} + +#[test] +fn handles_glob_exclude_patterns() { + let infos = make_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"]); +} + +#[test] +fn errors_if_package_in_multiple_groups() { + let infos = make_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")); +} + +#[test] +fn omits_empty_groups() { + let infos = make_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(); + // The group exists but has no packages + 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..82cf73a48 --- /dev/null +++ b/rust/tests/path_included_test.rs @@ -0,0 +1,58 @@ +use beachball::monorepo::path_included::is_path_included; + +#[test] +fn returns_true_if_path_is_included_single_include() { + assert!(is_path_included("packages/a", &["packages/*".into()])); +} + +#[test] +fn returns_false_if_path_is_excluded_single_exclude() { + assert!(!is_path_included( + "packages/a", + &["packages/*".into(), "!packages/a".into()] + )); +} + +#[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(), + ] + )); +} + +#[test] +fn returns_false_if_path_is_excluded_multiple_exclude() { + assert!(!is_path_included( + "packages/a", + &[ + "packages/*".into(), + "!packages/a".into(), + "!packages/b".into(), + ] + )); +} + +#[test] +fn returns_false_if_no_patterns_match() { + assert!(!is_path_included("packages/a", &["other/*".into()])); +} + +#[test] +fn returns_true_if_only_negation_patterns_none_match() { + assert!(is_path_included("packages/a", &["!packages/b".into()])); +} + +#[test] +fn returns_false_if_only_negation_patterns_matches() { + assert!(!is_path_included("packages/a", &["!packages/a".into()])); +} + +#[test] +fn ignores_empty_exclude_array() { + assert!(is_path_included("packages/a", &["packages/*".into()])); +} diff --git a/rust/tests/write_change_files_test.rs b/rust/tests/write_change_files_test.rs new file mode 100644 index 000000000..48394492f --- /dev/null +++ b/rust/tests/write_change_files_test.rs @@ -0,0 +1,129 @@ +mod common; + +use beachball::changefile::write_change_files::write_change_files; +use beachball::options::get_options::get_parsed_options_for_test; +use beachball::types::change_info::{ChangeFileInfo, ChangeType}; +use beachball::types::options::{BeachballOptions, CliOptions}; +use common::repository_factory::RepositoryFactory; + +const DEFAULT_BRANCH: &str = "master"; +const DEFAULT_REMOTE_BRANCH: &str = "origin/master"; + +fn make_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 +} + +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, + }, + ] +} + +#[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_options(repo.root_path(), None); + let changes = make_changes(); + + let result = write_change_files(&changes, &options).unwrap(); + assert_eq!(result.len(), 2); + + // Verify files exist on disk + 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}" + ); +} + +#[test] +fn respects_change_dir_option() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + repo.checkout(&["-b", "test", DEFAULT_BRANCH]); + + let mut custom_opts = BeachballOptions::default(); + custom_opts.change_dir = "customChangeDir".to_string(); + + let options = make_options(repo.root_path(), Some(custom_opts)); + let changes = make_changes(); + + let result = write_change_files(&changes, &options).unwrap(); + assert_eq!(result.len(), 2); + + // Verify files are in the custom directory + 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}" + ); + } +} + +#[test] +fn respects_commit_false() { + let factory = RepositoryFactory::new("monorepo"); + let repo = factory.clone_repository(); + repo.checkout(&["-b", "test", DEFAULT_BRANCH]); + + // Get current HEAD hash before writing + let hash_before = repo.git(&["rev-parse", "HEAD"]); + + let mut no_commit_opts = BeachballOptions::default(); + no_commit_opts.commit = false; + + let options = make_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); + + // Verify files exist on disk + for path in &result { + assert!( + std::path::Path::new(path).exists(), + "Change file should exist: {path}" + ); + } + + // Verify no new commit was created + let hash_after = repo.git(&["rev-parse", "HEAD"]); + assert_eq!( + hash_before, hash_after, + "HEAD should not change when commit=false" + ); +} From ecc59e6401a3d2df3be7226274b69c620cb646e7 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Fri, 6 Mar 2026 10:48:54 -0800 Subject: [PATCH 03/38] linting and formatting --- .github/workflows/pr.yml | 18 +++ .vscode/settings.json | 8 + go/README.md | 20 +++ go/internal/types/options.go | 46 +++--- go/internal/types/package_info.go | 10 +- package.json | 6 +- rust/README.md | 26 +++ rust/src/changefile/change_types.rs | 7 +- rust/src/changefile/changed_packages.rs | 6 +- rust/src/changefile/write_change_files.rs | 10 +- rust/src/commands/change.rs | 27 ++-- rust/src/commands/check.rs | 2 +- rust/src/git/commands.rs | 57 +++++-- rust/src/git/ensure_shared_history.rs | 2 +- rust/src/logging.rs | 6 +- rust/src/monorepo/package_groups.rs | 9 +- rust/src/monorepo/package_infos.rs | 35 +++-- rust/src/monorepo/scoped_packages.rs | 5 +- rust/src/options/get_options.rs | 17 +- rust/src/options/repo_options.rs | 17 +- rust/src/validation/validate.rs | 73 +++++---- rust/src/validation/validators.rs | 20 +-- rust/tests/are_change_files_deleted_test.rs | 6 +- rust/tests/change_test.rs | 40 +++-- rust/tests/changed_packages_test.rs | 155 +++++++++++------- rust/tests/common/fixtures.rs | 166 ++++++++++++-------- rust/tests/common/mod.rs | 4 + rust/tests/common/repository.rs | 16 +- rust/tests/common/repository_factory.rs | 28 ++-- rust/tests/validate_test.rs | 54 ++++--- rust/tests/write_change_files_test.rs | 12 +- 31 files changed, 567 insertions(+), 341 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e8fbef300..27158d067 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -100,6 +100,8 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt - name: Cache cargo uses: actions/cache@v4 @@ -110,6 +112,14 @@ jobs: rust/target key: ${{ runner.os }}-cargo-${{ hashFiles('rust/Cargo.lock') }} + - name: Format + run: cargo fmt --check + working-directory: ./rust + + - name: Lint + run: cargo clippy --all-targets -- -D warnings + working-directory: ./rust + - name: Build run: cargo build working-directory: ./rust @@ -137,6 +147,14 @@ jobs: go-version-file: go/go.mod cache-dependency-path: go/go.sum + - name: Format + run: test -z "$(gofmt -l .)" + working-directory: ./go + + - name: Lint + run: go vet ./... + working-directory: ./go + - name: Build run: go build ./... working-directory: ./go diff --git a/.vscode/settings.json b/.vscode/settings.json index ccfd36363..9a9c7ec14 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,6 +15,14 @@ "**/*.svg": true, "**/.yarn": 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. // Note that Prettier must also be configured to format *.json5 as jsonc diff --git a/go/README.md b/go/README.md index 62f983064..0ac0c3784 100644 --- a/go/README.md +++ b/go/README.md @@ -14,6 +14,26 @@ 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 diff --git a/go/internal/types/options.go b/go/internal/types/options.go index d8e4a468c..b0710ada4 100644 --- a/go/internal/types/options.go +++ b/go/internal/types/options.go @@ -2,36 +2,36 @@ package types // BeachballOptions holds all beachball configuration. type BeachballOptions struct { - All bool - Branch string - Command string - ChangeDir string - ChangeHint string - Commit bool - DependentChangeType string + All bool + Branch string + Command string + ChangeDir string + ChangeHint string + Commit bool + DependentChangeType string DisallowDeletedChangeFiles bool - Fetch bool - GroupChanges bool - IgnorePatterns []string - Message string - Package []string - Path string - Scope []string - Type string - Token string - AuthType string - Verbose bool - Groups []VersionGroupOptions + Fetch bool + GroupChanges bool + IgnorePatterns []string + Message string + Package []string + Path string + Scope []string + Type string + Token string + AuthType string + Verbose bool + Groups []VersionGroupOptions } // DefaultOptions returns BeachballOptions with sensible defaults. func DefaultOptions() BeachballOptions { return BeachballOptions{ - Branch: "origin/master", - ChangeDir: "change", + Branch: "origin/master", + ChangeDir: "change", ChangeHint: "Run 'beachball change' to create a change file", - Commit: true, - Fetch: true, + Commit: true, + Fetch: true, } } diff --git a/go/internal/types/package_info.go b/go/internal/types/package_info.go index 54d3aa382..baca9efcb 100644 --- a/go/internal/types/package_info.go +++ b/go/internal/types/package_info.go @@ -12,9 +12,9 @@ type PackageJson struct { // PackageOptions represents beachball-specific options in package.json. type PackageOptions struct { - ShouldPublish *bool `json:"shouldPublish,omitempty"` - DisallowedChangeTypes []string `json:"disallowedChangeTypes,omitempty"` - DefaultNearestBumpType string `json:"defaultNearestBumpType,omitempty"` + ShouldPublish *bool `json:"shouldPublish,omitempty"` + DisallowedChangeTypes []string `json:"disallowedChangeTypes,omitempty"` + DefaultNearestBumpType string `json:"defaultNearestBumpType,omitempty"` } // PackageInfo holds information about a single package. @@ -34,8 +34,8 @@ type ScopedPackages map[string]bool // PackageGroup represents a version group. type PackageGroup struct { - Name string - Packages []string + Name string + Packages []string DisallowedChangeTypes []string } diff --git a/package.json b/package.json index 52a89221c..c437deb44 100644 --- a/package.json +++ b/package.json @@ -46,9 +46,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/README.md b/rust/README.md index 62905091c..77a865164 100644 --- a/rust/README.md +++ b/rust/README.md @@ -14,6 +14,32 @@ 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 +``` + +**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 diff --git a/rust/src/changefile/change_types.rs b/rust/src/changefile/change_types.rs index a41abe0b6..47bea807b 100644 --- a/rust/src/changefile/change_types.rs +++ b/rust/src/changefile/change_types.rs @@ -30,9 +30,10 @@ pub fn get_disallowed_change_types( // 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(); - } + && 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 index ef1afad22..963f78d2e 100644 --- a/rust/src/changefile/changed_packages.rs +++ b/rust/src/changefile/changed_packages.rs @@ -20,11 +20,7 @@ fn is_package_included( 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) => + if info.package_options.as_ref().and_then(|o| o.should_publish) == Some(false) => { ( false, diff --git a/rust/src/changefile/write_change_files.rs b/rust/src/changefile/write_change_files.rs index 55f4a968a..51f295e94 100644 --- a/rust/src/changefile/write_change_files.rs +++ b/rust/src/changefile/write_change_files.rs @@ -41,7 +41,9 @@ pub fn write_change_files( } 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 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")) @@ -70,7 +72,11 @@ pub fn write_change_files( println!( "git {} these change files:{}", - if options.commit { "committed" } else { "staged" }, + if options.commit { + "committed" + } else { + "staged" + }, change_files .iter() .map(|f| format!("\n - {f}")) diff --git a/rust/src/commands/change.rs b/rust/src/commands/change.rs index 640bce9d9..22d642478 100644 --- a/rust/src/commands/change.rs +++ b/rust/src/commands/change.rs @@ -1,11 +1,11 @@ -use anyhow::{bail, Result}; +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::types::change_info::{ChangeFileInfo, ChangeType}; use crate::types::options::ParsedOptions; -use crate::validation::validate::{validate, ValidateOptions, ValidationResult}; +use crate::validation::validate::{ValidateOptions, ValidationResult, validate}; /// Run the change command (non-interactive only). /// Requires --type and --message to be specified. @@ -32,11 +32,9 @@ pub fn change(parsed: &ParsedOptions) -> Result<()> { return Ok(()); } - let changed = changed_packages - .unwrap_or_else(|| { - get_changed_packages(options, &package_infos, &scoped_packages) - .unwrap_or_default() - }); + let changed = changed_packages.unwrap_or_else(|| { + get_changed_packages(options, &package_infos, &scoped_packages).unwrap_or_default() + }); if changed.is_empty() { return Ok(()); @@ -54,13 +52,14 @@ pub fn change(parsed: &ParsedOptions) -> Result<()> { 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 dependent_change_type = + options + .dependent_change_type + .unwrap_or(if change_type == ChangeType::None { + ChangeType::None + } else { + ChangeType::Patch + }); let changes: Vec = changed .iter() diff --git a/rust/src/commands/check.rs b/rust/src/commands/check.rs index 40ea1fc95..5a6224e1c 100644 --- a/rust/src/commands/check.rs +++ b/rust/src/commands/check.rs @@ -1,7 +1,7 @@ use anyhow::Result; use crate::types::options::ParsedOptions; -use crate::validation::validate::{validate, ValidateOptions}; +use crate::validation::validate::{ValidateOptions, validate}; /// Run the check command: validate that change files are present where needed. pub fn check(parsed: &ParsedOptions) -> Result<()> { diff --git a/rust/src/git/commands.rs b/rust/src/git/commands.rs index f46e2407f..e7b720ea8 100644 --- a/rust/src/git/commands.rs +++ b/rust/src/git/commands.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use std::path::Path; use std::process::Command; @@ -58,11 +58,12 @@ pub fn find_project_root(cwd: &str) -> Result { let pkg_json = dir.join("package.json"); if pkg_json.exists() && let Ok(contents) = std::fs::read_to_string(&pkg_json) - && let Ok(pkg) = - serde_json::from_str::(&contents) - && pkg.workspaces.is_some() { - return Ok(dir.to_string_lossy().to_string()); - } + && let Ok(pkg) = + serde_json::from_str::(&contents) + && pkg.workspaces.is_some() + { + return Ok(dir.to_string_lossy().to_string()); + } if dir == git_root_path { break; @@ -94,7 +95,14 @@ pub fn get_user_email(cwd: &str) -> Option { /// 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", "--no-renames", &format!("{branch}...")], + &[ + "--no-pager", + "diff", + "--name-only", + "--relative", + "--no-renames", + &format!("{branch}..."), + ], cwd, )?; if !result.success { @@ -110,7 +118,17 @@ pub fn get_branch_changes(branch: &str, cwd: &str) -> Result> { /// Get staged changes. pub fn get_staged_changes(cwd: &str) -> Result> { - let result = git(&["--no-pager", "diff", "--cached", "--name-only", "--relative", "--no-renames"], cwd)?; + let result = git( + &[ + "--no-pager", + "diff", + "--cached", + "--name-only", + "--relative", + "--no-renames", + ], + cwd, + )?; if !result.success { return Ok(vec![]); } @@ -131,7 +149,13 @@ pub fn get_changes_between_refs( ) -> 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", "--no-renames"]; + let mut args: Vec<&str> = vec![ + "--no-pager", + "diff", + "--name-only", + "--relative", + "--no-renames", + ]; if let Some(ref flag) = diff_flag { args.push(flag); } @@ -232,15 +256,16 @@ pub fn get_default_remote_branch(cwd: &str) -> Result { // Try to get the default branch from remote let result = git(&["remote", "show", remote], cwd); if let Ok(r) = result - && r.success { - for line in r.stdout.lines() { - let trimmed = line.trim(); - if trimmed.starts_with("HEAD branch:") { - let branch = trimmed.trim_start_matches("HEAD branch:").trim(); - return Ok(format!("{remote}/{branch}")); - } + && r.success + { + for line in r.stdout.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("HEAD branch:") { + let branch = trimmed.trim_start_matches("HEAD branch:").trim(); + return Ok(format!("{remote}/{branch}")); } } + } // Fallback: try git config init.defaultBranch if let Ok(default_branch) = git_stdout(&["config", "init.defaultBranch"], cwd) { diff --git a/rust/src/git/ensure_shared_history.rs b/rust/src/git/ensure_shared_history.rs index f515b48cb..decd926f0 100644 --- a/rust/src/git/ensure_shared_history.rs +++ b/rust/src/git/ensure_shared_history.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Result}; +use anyhow::{Result, bail}; use crate::types::options::BeachballOptions; diff --git a/rust/src/logging.rs b/rust/src/logging.rs index ff1e31e90..dd13a3158 100644 --- a/rust/src/logging.rs +++ b/rust/src/logging.rs @@ -1,4 +1,8 @@ /// Format items as a bulleted list. pub fn bulleted_list(items: &[&str]) -> String { - items.iter().map(|item| format!(" • {item}")).collect::>().join("\n") + items + .iter() + .map(|item| format!(" • {item}")) + .collect::>() + .join("\n") } diff --git a/rust/src/monorepo/package_groups.rs b/rust/src/monorepo/package_groups.rs index c75191dc5..d16cac13e 100644 --- a/rust/src/monorepo/package_groups.rs +++ b/rust/src/monorepo/package_groups.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Result}; +use anyhow::{Result, bail}; use crate::types::options::VersionGroupInclude; use crate::types::package_info::{PackageGroupInfo, PackageGroups, PackageInfos}; @@ -39,9 +39,10 @@ pub fn get_package_groups( // Check exclude patterns if let Some(ref exclude) = group.exclude - && !is_path_included(&rel_path, exclude) { - continue; - } + && !is_path_included(&rel_path, exclude) + { + continue; + } // Check for multi-group membership if let Some(existing_group) = package_to_group.get(&info.name) { diff --git a/rust/src/monorepo/package_infos.rs b/rust/src/monorepo/package_infos.rs index dab0e4785..a3cdfc4f1 100644 --- a/rust/src/monorepo/package_infos.rs +++ b/rust/src/monorepo/package_infos.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Result}; +use anyhow::{Result, bail}; use std::path::{Path, PathBuf}; use crate::types::options::BeachballOptions; @@ -13,8 +13,7 @@ pub fn get_package_infos(options: &BeachballOptions) -> Result { bail!("No package.json found at {cwd}"); } - let root_pkg: PackageJson = - serde_json::from_str(&std::fs::read_to_string(&root_pkg_path)?)?; + let root_pkg: PackageJson = serde_json::from_str(&std::fs::read_to_string(&root_pkg_path)?)?; let mut infos = PackageInfos::new(); @@ -30,18 +29,19 @@ pub fn get_package_infos(options: &BeachballOptions) -> Result { for entry in entries.flatten() { let pkg_json_path = entry.join("package.json"); if pkg_json_path.exists() - && let Ok(info) = read_package_info(&pkg_json_path) { - if infos.contains_key(&info.name) { - 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); + && let Ok(info) = read_package_info(&pkg_json_path) + { + if infos.contains_key(&info.name) { + 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); + } } } } else { @@ -61,9 +61,10 @@ 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() - }); + let package_options = pkg + .beachball + .as_ref() + .and_then(|bb| serde_json::from_value::(bb.clone()).ok()); Ok(PackageInfo { name: pkg.name.clone(), diff --git a/rust/src/monorepo/scoped_packages.rs b/rust/src/monorepo/scoped_packages.rs index 712588a14..ff3a0d824 100644 --- a/rust/src/monorepo/scoped_packages.rs +++ b/rust/src/monorepo/scoped_packages.rs @@ -5,7 +5,10 @@ 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 { +pub fn get_scoped_packages( + options: &BeachballOptions, + package_infos: &PackageInfos, +) -> ScopedPackages { let scope = match &options.scope { Some(s) if !s.is_empty() => s, _ => { diff --git a/rust/src/options/get_options.rs b/rust/src/options/get_options.rs index dd31679cd..5cc971a4f 100644 --- a/rust/src/options/get_options.rs +++ b/rust/src/options/get_options.rs @@ -19,10 +19,11 @@ pub fn get_parsed_options(cwd: &str, cli: CliOptions) -> Result { // If branch doesn't contain '/', resolve the remote if let Some(ref branch) = cli.branch && !branch.contains('/') - && let Ok(default) = commands::get_default_remote_branch(cwd) - && let Some((remote, _)) = commands::parse_remote_branch(&default) { - merged.branch = format!("{remote}/{branch}"); - } + && let Ok(default) = commands::get_default_remote_branch(cwd) + && let Some((remote, _)) = commands::parse_remote_branch(&default) + { + merged.branch = format!("{remote}/{branch}"); + } Ok(ParsedOptions { cli_options: cli, @@ -79,11 +80,11 @@ fn merge_options(base: BeachballOptions, overlay: BeachballOptions) -> Beachball 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), + 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" - { + changehint: if overlay.changehint != "Run \"beachball change\" to create a change file" { overlay.changehint } else { base.changehint diff --git a/rust/src/options/repo_options.rs b/rust/src/options/repo_options.rs index 2ab145e42..cb00f4b7e 100644 --- a/rust/src/options/repo_options.rs +++ b/rust/src/options/repo_options.rs @@ -61,17 +61,18 @@ fn search_for_config(cwd: &str) -> Result> { // Check for .beachballrc.json let rc_path = dir.join(".beachballrc.json"); if rc_path.exists() - && let Ok(config) = load_json_config(rc_path.to_str().unwrap_or_default()) { - return Ok(config); - } + && let Ok(config) = load_json_config(rc_path.to_str().unwrap_or_default()) + { + return Ok(config); + } // Check for package.json "beachball" field let pkg_path = dir.join("package.json"); if pkg_path.exists() && let Ok(Some(config)) = load_from_package_json(pkg_path.to_str().unwrap_or_default()) - { - return Ok(Some(config)); - } + { + return Ok(Some(config)); + } // Stop at git root if dir == git_root_path { @@ -158,9 +159,7 @@ fn apply_raw_config(opts: &mut BeachballOptions, raw: RawRepoConfig, cwd: &str) .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::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())) diff --git a/rust/src/validation/validate.rs b/rust/src/validation/validate.rs index 8e4b94094..2dbc9c6ff 100644 --- a/rust/src/validation/validate.rs +++ b/rust/src/validation/validate.rs @@ -107,36 +107,40 @@ pub fn validate( // 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, - ); - } + && !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, - ); - } + && !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, - ); - } + && !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; - } + && !is_valid_group_options(groups) + { + has_error = true; + } // Get package groups let package_groups = get_package_groups(&package_infos, &options.path, &options.groups)?; @@ -170,15 +174,16 @@ pub fn validate( &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, - ); - } + && 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) { @@ -244,12 +249,12 @@ pub fn validate( } // Skip checkDependencies / bumpInMemory (not implemented) - if validate_options.check_dependencies && !is_change_needed && !change_set.is_empty() + if validate_options.check_dependencies + && !is_change_needed + && !change_set.is_empty() && options.verbose { - println!( - "(Skipping package dependency validation — not implemented in Rust port)" - ); + println!("(Skipping package dependency validation — not implemented in Rust port)"); } println!(); diff --git a/rust/src/validation/validators.rs b/rust/src/validation/validators.rs index 3fb0c07f9..181611015 100644 --- a/rust/src/validation/validators.rs +++ b/rust/src/validation/validators.rs @@ -26,9 +26,10 @@ pub fn is_valid_dependent_change_type( return false; } if let Some(disallowed) = disallowed - && disallowed.contains(&ct) { - return false; - } + && disallowed.contains(&ct) + { + return false; + } true } @@ -54,13 +55,14 @@ pub fn is_valid_grouped_package_options( 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() { - eprintln!( - "ERROR: Package \"{pkg_name}\" has disallowedChangeTypes but is in a group. \ + && opts.disallowed_change_types.is_some() + { + eprintln!( + "ERROR: Package \"{pkg_name}\" has disallowedChangeTypes but is in a group. \ Group-level disallowedChangeTypes take precedence." - ); - valid = false; - } + ); + valid = false; + } } } valid diff --git a/rust/tests/are_change_files_deleted_test.rs b/rust/tests/are_change_files_deleted_test.rs index 37c62234c..56b077b06 100644 --- a/rust/tests/are_change_files_deleted_test.rs +++ b/rust/tests/are_change_files_deleted_test.rs @@ -63,8 +63,10 @@ fn works_with_custom_change_dir() { let factory = RepositoryFactory::new("monorepo"); let repo = factory.clone_repository(); - let mut custom_opts = BeachballOptions::default(); - custom_opts.change_dir = "changeDir".to_string(); + let custom_opts = BeachballOptions { + change_dir: "changeDir".to_string(), + ..Default::default() + }; let options = make_options(repo.root_path(), Some(custom_opts.clone())); generate_change_files(&["foo"], &options, &repo); diff --git a/rust/tests/change_test.rs b/rust/tests/change_test.rs index b47e4054b..44b63ec4b 100644 --- a/rust/tests/change_test.rs +++ b/rust/tests/change_test.rs @@ -18,14 +18,18 @@ fn does_not_create_change_files_when_no_changes() { let branch_name = "no-changes-test"; repo.checkout(&["-b", branch_name, DEFAULT_BRANCH]); - let mut repo_opts = BeachballOptions::default(); - repo_opts.branch = DEFAULT_REMOTE_BRANCH.to_string(); - repo_opts.fetch = false; + let repo_opts = BeachballOptions { + branch: DEFAULT_REMOTE_BRANCH.to_string(), + fetch: false, + ..Default::default() + }; - let mut cli = CliOptions::default(); - cli.command = Some("change".to_string()); - cli.message = Some("test change".to_string()); - cli.change_type = Some(ChangeType::Patch); + let cli = CliOptions { + command: Some("change".to_string()), + message: Some("test change".to_string()), + change_type: Some(ChangeType::Patch), + ..Default::default() + }; let parsed = get_parsed_options_for_test(repo.root_path(), cli, repo_opts); let result = change(&parsed); @@ -44,16 +48,20 @@ fn creates_change_file_with_type_and_message() { repo.checkout(&["-b", branch_name, DEFAULT_BRANCH]); repo.commit_change("file.js"); - let mut repo_opts = BeachballOptions::default(); - repo_opts.branch = DEFAULT_REMOTE_BRANCH.to_string(); - repo_opts.fetch = false; - repo_opts.commit = false; + let repo_opts = BeachballOptions { + branch: DEFAULT_REMOTE_BRANCH.to_string(), + fetch: false, + commit: false, + ..Default::default() + }; - let mut cli = CliOptions::default(); - cli.command = Some("change".to_string()); - cli.message = Some("test description".to_string()); - cli.change_type = Some(ChangeType::Patch); - cli.commit = Some(false); + let cli = CliOptions { + command: Some("change".to_string()), + message: Some("test description".to_string()), + change_type: Some(ChangeType::Patch), + commit: Some(false), + ..Default::default() + }; let parsed = get_parsed_options_for_test(repo.root_path(), cli, repo_opts); let result = change(&parsed); diff --git a/rust/tests/changed_packages_test.rs b/rust/tests/changed_packages_test.rs index 5f834bba4..327df0c8c 100644 --- a/rust/tests/changed_packages_test.rs +++ b/rust/tests/changed_packages_test.rs @@ -17,7 +17,11 @@ fn get_options_and_packages( repo: &Repository, overrides: Option, extra_cli: Option, -) -> (BeachballOptions, beachball::types::package_info::PackageInfos, beachball::types::package_info::ScopedPackages) { +) -> ( + 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(); @@ -75,14 +79,22 @@ fn returns_given_package_names_as_is() { let factory = RepositoryFactory::new("monorepo"); let repo = factory.clone_repository(); - let mut cli = CliOptions::default(); - cli.package = Some(vec!["foo".to_string()]); + 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 mut cli2 = CliOptions::default(); - cli2.package = Some(vec!["foo".to_string(), "bar".to_string(), "nope".to_string()]); + 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"]); @@ -93,8 +105,10 @@ fn returns_all_packages_with_all_true() { let factory = RepositoryFactory::new("monorepo"); let repo = factory.clone_repository(); - let mut opts = BeachballOptions::default(); - opts.all = true; + 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(); @@ -109,7 +123,11 @@ fn detects_changed_files_in_single_package_repo() { 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()); + assert!( + get_changed_packages(&options, &infos, &scoped) + .unwrap() + .is_empty() + ); repo.stage_change("foo.js"); let result = get_changed_packages(&options, &infos, &scoped).unwrap(); @@ -121,13 +139,15 @@ fn respects_ignore_patterns() { let factory = RepositoryFactory::new("single"); let repo = factory.clone_repository(); - let mut opts = BeachballOptions::default(); - opts.ignore_patterns = Some(vec![ - "*.test.js".to_string(), - "tests/**".to_string(), - "yarn.lock".to_string(), - ]); - opts.verbose = true; + 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); @@ -148,7 +168,11 @@ fn detects_changed_files_in_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()); + 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(); @@ -162,8 +186,10 @@ fn excludes_packages_with_existing_change_files() { repo.checkout(&["-b", "test"]); repo.commit_change("packages/foo/test.js"); - let mut opts = BeachballOptions::default(); - opts.verbose = true; + 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); @@ -181,22 +207,37 @@ fn ignores_package_changes_as_appropriate() { use serde_json::json; let mut packages: HashMap = HashMap::new(); - packages.insert("private-pkg".to_string(), json!({ - "name": "private-pkg", "version": "1.0.0", "private": true - })); - packages.insert("no-publish".to_string(), json!({ - "name": "no-publish", "version": "1.0.0", - "beachball": { "shouldPublish": false } - })); - packages.insert("out-of-scope".to_string(), json!({ - "name": "out-of-scope", "version": "1.0.0" - })); - packages.insert("ignore-pkg".to_string(), json!({ - "name": "ignore-pkg", "version": "1.0.0" - })); - packages.insert("publish-me".to_string(), json!({ - "name": "publish-me", "version": "1.0.0" - })); + packages.insert( + "private-pkg".to_string(), + json!({ + "name": "private-pkg", "version": "1.0.0", "private": true + }), + ); + packages.insert( + "no-publish".to_string(), + json!({ + "name": "no-publish", "version": "1.0.0", + "beachball": { "shouldPublish": false } + }), + ); + packages.insert( + "out-of-scope".to_string(), + json!({ + "name": "out-of-scope", "version": "1.0.0" + }), + ); + packages.insert( + "ignore-pkg".to_string(), + json!({ + "name": "ignore-pkg", "version": "1.0.0" + }), + ); + packages.insert( + "publish-me".to_string(), + json!({ + "name": "publish-me", "version": "1.0.0" + }), + ); let root = json!({ "name": "test-monorepo", @@ -215,10 +256,12 @@ fn ignores_package_changes_as_appropriate() { repo.stage_change("packages/ignore-pkg/CHANGELOG.md"); repo.stage_change("packages/publish-me/test.js"); - let mut opts = BeachballOptions::default(); - opts.scope = Some(vec!["!packages/out-of-scope".to_string()]); - opts.ignore_patterns = Some(vec!["**/jest.config.js".to_string()]); - opts.verbose = true; + 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() + }; let (options, infos, scoped) = get_options_and_packages(&repo, Some(opts), None); let result = get_changed_packages(&options, &infos, &scoped).unwrap(); @@ -234,16 +277,14 @@ fn detects_changed_files_in_multi_root_monorepo() { // Test from project-a root let path_a = repo.path_to(&["project-a"]).to_string_lossy().to_string(); - let mut opts_a = BeachballOptions::default(); - opts_a.path = path_a.clone(); - opts_a.branch = DEFAULT_REMOTE_BRANCH.to_string(); - opts_a.fetch = false; - - let parsed_a = get_parsed_options_for_test( - &path_a, - CliOptions::default(), - opts_a, - ); + 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(); @@ -251,16 +292,14 @@ fn detects_changed_files_in_multi_root_monorepo() { // Test from project-b root let path_b = repo.path_to(&["project-b"]).to_string_lossy().to_string(); - let mut opts_b = BeachballOptions::default(); - opts_b.path = path_b.clone(); - opts_b.branch = DEFAULT_REMOTE_BRANCH.to_string(); - opts_b.fetch = false; - - let parsed_b = get_parsed_options_for_test( - &path_b, - CliOptions::default(), - opts_b, - ); + 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(); diff --git a/rust/tests/common/fixtures.rs b/rust/tests/common/fixtures.rs index f84d7c229..6a4fc512c 100644 --- a/rust/tests/common/fixtures.rs +++ b/rust/tests/common/fixtures.rs @@ -1,6 +1,12 @@ 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)>, +); + /// Package definition for a fixture. pub struct PackageFixture { pub name: Option, @@ -28,11 +34,11 @@ impl PackageFixture { if let Some(ref bb) = self.beachball { obj.insert("beachball".to_string(), bb.clone()); } - if let Some(ref other) = self.other { - if let serde_json::Value::Object(map) = other { - for (k, v) in map { - obj.insert(k.clone(), v.clone()); - } + if let Some(ref other) = self.other + && let serde_json::Value::Object(map) = other + { + for (k, v) in map { + obj.insert(k.clone(), v.clone()); } } serde_json::Value::Object(obj) @@ -40,7 +46,7 @@ impl PackageFixture { } /// Fixture for a single-package repo. -pub fn single_package_fixture() -> (serde_json::Value, Vec<(String, HashMap)>) { +pub fn single_package_fixture() -> FixtureResult { let root = json!({ "name": "foo", "version": "1.0.0", @@ -53,7 +59,7 @@ pub fn single_package_fixture() -> (serde_json::Value, Vec<(String, HashMap (serde_json::Value, Vec<(String, HashMap)>) { +pub fn monorepo_fixture() -> FixtureResult { let root = json!({ "name": "monorepo-fixture", "version": "1.0.0", @@ -69,42 +75,60 @@ pub fn monorepo_fixture() -> (serde_json::Value, Vec<(String, HashMap = HashMap::from([ - ("foo".to_string(), json!({ - "name": "foo", - "version": "1.0.0", - "dependencies": { "bar": "^1.3.4" }, - "main": "src/index.ts" - })), - ("bar".to_string(), json!({ - "name": "bar", - "version": "1.3.4", - "dependencies": { "baz": "^1.3.4" } - })), - ("baz".to_string(), json!({ - "name": "baz", - "version": "1.3.4" - })), + ( + "foo".to_string(), + json!({ + "name": "foo", + "version": "1.0.0", + "dependencies": { "bar": "^1.3.4" }, + "main": "src/index.ts" + }), + ), + ( + "bar".to_string(), + json!({ + "name": "bar", + "version": "1.3.4", + "dependencies": { "baz": "^1.3.4" } + }), + ), + ( + "baz".to_string(), + json!({ + "name": "baz", + "version": "1.3.4" + }), + ), ]); let grouped: HashMap = HashMap::from([ - ("a".to_string(), json!({ - "name": "a", - "version": "3.1.2" - })), - ("b".to_string(), json!({ - "name": "b", - "version": "3.1.2" - })), + ( + "a".to_string(), + json!({ + "name": "a", + "version": "3.1.2" + }), + ), + ( + "b".to_string(), + json!({ + "name": "b", + "version": "3.1.2" + }), + ), ]); - (root, vec![ - ("packages".to_string(), packages), - ("packages/grouped".to_string(), grouped), - ]) + ( + root, + vec![ + ("packages".to_string(), packages), + ("packages/grouped".to_string(), grouped), + ], + ) } /// Fixture for a monorepo with a scope prefix (used in multi-project). -pub fn scoped_monorepo_fixture(scope: &str) -> (serde_json::Value, Vec<(String, HashMap)>) { +pub fn scoped_monorepo_fixture(scope: &str) -> FixtureResult { let root = json!({ "name": format!("@{scope}/monorepo-fixture"), "version": "1.0.0", @@ -120,36 +144,54 @@ pub fn scoped_monorepo_fixture(scope: &str) -> (serde_json::Value, Vec<(String, }); let packages: HashMap = HashMap::from([ - ("foo".to_string(), json!({ - "name": format!("@{scope}/foo"), - "version": "1.0.0", - "dependencies": { format!("@{scope}/bar"): "^1.3.4" }, - "main": "src/index.ts" - })), - ("bar".to_string(), json!({ - "name": format!("@{scope}/bar"), - "version": "1.3.4", - "dependencies": { format!("@{scope}/baz"): "^1.3.4" } - })), - ("baz".to_string(), json!({ - "name": format!("@{scope}/baz"), - "version": "1.3.4" - })), + ( + "foo".to_string(), + json!({ + "name": format!("@{scope}/foo"), + "version": "1.0.0", + "dependencies": { format!("@{scope}/bar"): "^1.3.4" }, + "main": "src/index.ts" + }), + ), + ( + "bar".to_string(), + json!({ + "name": format!("@{scope}/bar"), + "version": "1.3.4", + "dependencies": { format!("@{scope}/baz"): "^1.3.4" } + }), + ), + ( + "baz".to_string(), + json!({ + "name": format!("@{scope}/baz"), + "version": "1.3.4" + }), + ), ]); let grouped: HashMap = HashMap::from([ - ("a".to_string(), json!({ - "name": format!("@{scope}/a"), - "version": "3.1.2" - })), - ("b".to_string(), json!({ - "name": format!("@{scope}/b"), - "version": "3.1.2" - })), + ( + "a".to_string(), + json!({ + "name": format!("@{scope}/a"), + "version": "3.1.2" + }), + ), + ( + "b".to_string(), + json!({ + "name": format!("@{scope}/b"), + "version": "3.1.2" + }), + ), ]); - (root, vec![ - ("packages".to_string(), packages), - ("packages/grouped".to_string(), grouped), - ]) + ( + 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 index 5aa46f6ff..a067c89e1 100644 --- a/rust/tests/common/mod.rs +++ b/rust/tests/common/mod.rs @@ -1,4 +1,8 @@ +#[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; diff --git a/rust/tests/common/repository.rs b/rust/tests/common/repository.rs index d1c45c676..992618a4e 100644 --- a/rust/tests/common/repository.rs +++ b/rust/tests/common/repository.rs @@ -1,5 +1,5 @@ use std::fs; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::process::Command; /// A test git repository (cloned from a bare origin). @@ -15,7 +15,7 @@ impl Repository { .tempdir() .expect("failed to create temp dir"); - let root = tmp.into_path(); + let root = tmp.keep(); // Clone run_git(&["clone", bare_repo, root.to_str().unwrap()], "."); @@ -94,7 +94,7 @@ impl Repository { } pub fn clean_up(&self) { - if !std::env::var("CI").is_ok() { + if std::env::var("CI").is_err() { let _ = fs::remove_dir_all(&self.root); } } @@ -116,14 +116,8 @@ fn run_git(args: &[&str], cwd: &str) -> String { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); // Don't panic on expected failures - if !stderr.contains("already exists") - && !stderr.contains("nothing to commit") - { - panic!( - "git {} failed in {cwd}: {}", - args.join(" "), - stderr - ); + if !stderr.contains("already exists") && !stderr.contains("nothing to commit") { + panic!("git {} failed in {cwd}: {}", args.join(" "), stderr); } } diff --git a/rust/tests/common/repository_factory.rs b/rust/tests/common/repository_factory.rs index 4b51d8e22..ab7115166 100644 --- a/rust/tests/common/repository_factory.rs +++ b/rust/tests/common/repository_factory.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command; use super::fixtures; @@ -51,7 +51,7 @@ impl RepositoryFactory { .prefix(&format!("beachball-{description}-origin-")) .tempdir() .expect("failed to create temp dir"); - let root = tmp.into_path(); + let root = tmp.keep(); // Init bare repo run_git(&["init", "--bare"], root.to_str().unwrap()); @@ -65,7 +65,11 @@ impl RepositoryFactory { let clone_path = tmp_clone.path(); run_git( - &["clone", root.to_str().unwrap(), clone_path.to_str().unwrap()], + &[ + "clone", + root.to_str().unwrap(), + clone_path.to_str().unwrap(), + ], ".", ); @@ -124,7 +128,7 @@ impl RepositoryFactory { .prefix("beachball-multi-origin-") .tempdir() .expect("failed to create temp dir"); - let root = tmp.into_path(); + let root = tmp.keep(); // Init bare repo run_git(&["init", "--bare"], root.to_str().unwrap()); @@ -138,7 +142,11 @@ impl RepositoryFactory { let clone_path = tmp_clone.path(); run_git( - &["clone", root.to_str().unwrap(), clone_path.to_str().unwrap()], + &[ + "clone", + root.to_str().unwrap(), + clone_path.to_str().unwrap(), + ], ".", ); @@ -172,7 +180,7 @@ impl RepositoryFactory { } pub fn clean_up(&self) { - if !std::env::var("CI").is_ok() { + if std::env::var("CI").is_err() { let _ = fs::remove_dir_all(&self.root); } } @@ -207,7 +215,7 @@ fn write_project_fixture( } } -fn set_default_branch(bare_repo_path: &PathBuf) { +fn set_default_branch(bare_repo_path: &Path) { run_git( &["symbolic-ref", "HEAD", "refs/heads/master"], bare_repo_path.to_str().unwrap(), @@ -224,11 +232,7 @@ fn run_git(args: &[&str], cwd: &str) -> String { 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 - ); + panic!("git {} failed in {cwd}: {}", args.join(" "), stderr); } } diff --git a/rust/tests/validate_test.rs b/rust/tests/validate_test.rs index 5349a150a..b39d96ef3 100644 --- a/rust/tests/validate_test.rs +++ b/rust/tests/validate_test.rs @@ -2,22 +2,27 @@ mod common; use beachball::options::get_options::get_parsed_options_for_test; use beachball::types::options::{BeachballOptions, CliOptions}; -use beachball::validation::validate::{validate, ValidateOptions, ValidationError}; +use beachball::validation::validate::{ValidateOptions, ValidationError, validate}; use common::repository::Repository; use common::repository_factory::RepositoryFactory; const DEFAULT_REMOTE_BRANCH: &str = "origin/master"; -fn make_test_options(cwd: &str) -> (CliOptions, BeachballOptions) { +fn make_test_options() -> (CliOptions, BeachballOptions) { let cli = CliOptions::default(); - let mut repo_opts = BeachballOptions::default(); - repo_opts.branch = DEFAULT_REMOTE_BRANCH.to_string(); - repo_opts.fetch = false; + let repo_opts = BeachballOptions { + branch: DEFAULT_REMOTE_BRANCH.to_string(), + fetch: false, + ..Default::default() + }; (cli, repo_opts) } -fn validate_wrapper(repo: &Repository, validate_options: ValidateOptions) -> Result { - let (cli, repo_opts) = make_test_options(repo.root_path()); +fn validate_wrapper( + repo: &Repository, + validate_options: ValidateOptions, +) -> Result { + let (cli, repo_opts) = make_test_options(); let parsed = get_parsed_options_for_test(repo.root_path(), cli, repo_opts); validate(&parsed, &validate_options) } @@ -28,10 +33,13 @@ fn succeeds_with_no_changes() { let repo = factory.clone_repository(); repo.checkout(&["-b", "test"]); - let result = validate_wrapper(&repo, ValidateOptions { - check_change_needed: true, - ..Default::default() - }); + let result = validate_wrapper( + &repo, + ValidateOptions { + check_change_needed: true, + ..Default::default() + }, + ); assert!(result.is_ok()); let result = result.unwrap(); @@ -45,10 +53,13 @@ fn exits_with_error_if_change_files_needed() { repo.checkout(&["-b", "test"]); repo.stage_change("packages/foo/test.js"); - let result = validate_wrapper(&repo, ValidateOptions { - check_change_needed: true, - ..Default::default() - }); + let result = validate_wrapper( + &repo, + ValidateOptions { + check_change_needed: true, + ..Default::default() + }, + ); let err = result.expect_err("expected validation to fail"); assert!(err.downcast_ref::().is_some()); @@ -61,11 +72,14 @@ fn returns_without_error_if_allow_missing_change_files() { repo.checkout(&["-b", "test"]); repo.stage_change("packages/foo/test.js"); - let result = validate_wrapper(&repo, ValidateOptions { - check_change_needed: true, - allow_missing_change_files: true, - ..Default::default() - }); + let result = validate_wrapper( + &repo, + ValidateOptions { + check_change_needed: true, + allow_missing_change_files: true, + ..Default::default() + }, + ); assert!(result.is_ok()); let result = result.unwrap(); diff --git a/rust/tests/write_change_files_test.rs b/rust/tests/write_change_files_test.rs index 48394492f..ab88aa253 100644 --- a/rust/tests/write_change_files_test.rs +++ b/rust/tests/write_change_files_test.rs @@ -72,8 +72,10 @@ fn respects_change_dir_option() { let repo = factory.clone_repository(); repo.checkout(&["-b", "test", DEFAULT_BRANCH]); - let mut custom_opts = BeachballOptions::default(); - custom_opts.change_dir = "customChangeDir".to_string(); + let custom_opts = BeachballOptions { + change_dir: "customChangeDir".to_string(), + ..Default::default() + }; let options = make_options(repo.root_path(), Some(custom_opts)); let changes = make_changes(); @@ -103,8 +105,10 @@ fn respects_commit_false() { // Get current HEAD hash before writing let hash_before = repo.git(&["rev-parse", "HEAD"]); - let mut no_commit_opts = BeachballOptions::default(); - no_commit_opts.commit = false; + let no_commit_opts = BeachballOptions { + commit: false, + ..Default::default() + }; let options = make_options(repo.root_path(), Some(no_commit_opts)); let changes = make_changes(); From fb5bc413a2bc2de0e525719f4a172b7a6043f254 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Fri, 6 Mar 2026 14:17:58 -0800 Subject: [PATCH 04/38] rust test improvements --- rust/src/types/package_info.rs | 2 +- rust/tests/are_change_files_deleted_test.rs | 29 +--- rust/tests/change_test.rs | 25 +-- rust/tests/changed_packages_test.rs | 66 +++---- rust/tests/common/fixtures.rs | 183 ++++---------------- rust/tests/common/mod.rs | 40 +++++ rust/tests/common/repository.rs | 21 +-- rust/tests/common/repository_factory.rs | 19 +- rust/tests/disallowed_change_types_test.rs | 56 +++--- rust/tests/package_groups_test.rs | 25 +-- rust/tests/validate_test.rs | 25 +-- rust/tests/write_change_files_test.rs | 28 +-- 12 files changed, 167 insertions(+), 352 deletions(-) diff --git a/rust/src/types/package_info.rs b/rust/src/types/package_info.rs index b76a4fcf2..41ac6e1bf 100644 --- a/rust/src/types/package_info.rs +++ b/rust/src/types/package_info.rs @@ -45,7 +45,7 @@ pub struct PackageOptions { } /// Internal representation of a package. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct PackageInfo { pub name: String, pub package_json_path: String, diff --git a/rust/tests/are_change_files_deleted_test.rs b/rust/tests/are_change_files_deleted_test.rs index 56b077b06..e1f50c705 100644 --- a/rust/tests/are_change_files_deleted_test.rs +++ b/rust/tests/are_change_files_deleted_test.rs @@ -1,36 +1,23 @@ mod common; -use beachball::options::get_options::get_parsed_options_for_test; -use beachball::types::options::{BeachballOptions, CliOptions}; +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; - -const DEFAULT_BRANCH: &str = "master"; -const DEFAULT_REMOTE_BRANCH: &str = "origin/master"; - -fn make_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 -} +use common::{DEFAULT_BRANCH, make_test_options}; #[test] fn is_false_when_no_change_files_are_deleted() { let factory = RepositoryFactory::new("monorepo"); let repo = factory.clone_repository(); - let options = make_options(repo.root_path(), None); + 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_options(repo.root_path(), None); + let options = make_test_options(repo.root_path(), None); assert!(!are_change_files_deleted(&options)); } @@ -39,7 +26,7 @@ fn is_true_when_change_files_are_deleted() { let factory = RepositoryFactory::new("monorepo"); let repo = factory.clone_repository(); - let options = make_options(repo.root_path(), None); + let options = make_test_options(repo.root_path(), None); generate_change_files(&["foo"], &options, &repo); repo.push(); @@ -54,7 +41,7 @@ fn is_true_when_change_files_are_deleted() { repo.git(&["add", "-A"]); repo.git(&["commit", "-m", "delete change files"]); - let options = make_options(repo.root_path(), None); + let options = make_test_options(repo.root_path(), None); assert!(are_change_files_deleted(&options)); } @@ -68,7 +55,7 @@ fn works_with_custom_change_dir() { ..Default::default() }; - let options = make_options(repo.root_path(), Some(custom_opts.clone())); + let options = make_test_options(repo.root_path(), Some(custom_opts.clone())); generate_change_files(&["foo"], &options, &repo); repo.push(); @@ -83,6 +70,6 @@ fn works_with_custom_change_dir() { repo.git(&["add", "-A"]); repo.git(&["commit", "-m", "delete change files"]); - let options = make_options(repo.root_path(), Some(custom_opts)); + 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 index 44b63ec4b..5eb0280bf 100644 --- a/rust/tests/change_test.rs +++ b/rust/tests/change_test.rs @@ -6,24 +6,19 @@ use beachball::types::change_info::{ChangeFileInfo, ChangeType}; use beachball::types::options::{BeachballOptions, CliOptions}; use common::change_files::get_change_files; use common::repository_factory::RepositoryFactory; - -const DEFAULT_BRANCH: &str = "master"; -const DEFAULT_REMOTE_BRANCH: &str = "origin/master"; +use common::{DEFAULT_BRANCH, DEFAULT_REMOTE_BRANCH}; #[test] fn does_not_create_change_files_when_no_changes() { let factory = RepositoryFactory::new("single"); let repo = factory.clone_repository(); - - let branch_name = "no-changes-test"; - repo.checkout(&["-b", branch_name, DEFAULT_BRANCH]); + repo.checkout(&["-b", "no-changes-test", DEFAULT_BRANCH]); let repo_opts = BeachballOptions { branch: DEFAULT_REMOTE_BRANCH.to_string(), fetch: false, ..Default::default() }; - let cli = CliOptions { command: Some("change".to_string()), message: Some("test change".to_string()), @@ -32,20 +27,15 @@ fn does_not_create_change_files_when_no_changes() { }; let parsed = get_parsed_options_for_test(repo.root_path(), cli, repo_opts); - let result = change(&parsed); - assert!(result.is_ok()); - - let files = get_change_files(&parsed.options); - assert!(files.is_empty()); + assert!(change(&parsed).is_ok()); + assert!(get_change_files(&parsed.options).is_empty()); } #[test] fn creates_change_file_with_type_and_message() { let factory = RepositoryFactory::new("single"); let repo = factory.clone_repository(); - - let branch_name = "creates-change-test"; - repo.checkout(&["-b", branch_name, DEFAULT_BRANCH]); + repo.checkout(&["-b", "creates-change-test", DEFAULT_BRANCH]); repo.commit_change("file.js"); let repo_opts = BeachballOptions { @@ -54,7 +44,6 @@ fn creates_change_file_with_type_and_message() { commit: false, ..Default::default() }; - let cli = CliOptions { command: Some("change".to_string()), message: Some("test description".to_string()), @@ -64,13 +53,11 @@ fn creates_change_file_with_type_and_message() { }; let parsed = get_parsed_options_for_test(repo.root_path(), cli, repo_opts); - let result = change(&parsed); - assert!(result.is_ok()); + assert!(change(&parsed).is_ok()); let files = get_change_files(&parsed.options); assert_eq!(files.len(), 1); - // Verify the change file content 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); diff --git a/rust/tests/changed_packages_test.rs b/rust/tests/changed_packages_test.rs index 327df0c8c..dad59d502 100644 --- a/rust/tests/changed_packages_test.rs +++ b/rust/tests/changed_packages_test.rs @@ -6,15 +6,13 @@ 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::Repository; use common::repository_factory::RepositoryFactory; +use common::{DEFAULT_BRANCH, DEFAULT_REMOTE_BRANCH}; +use serde_json::json; use std::collections::HashMap; -const DEFAULT_BRANCH: &str = "master"; -const DEFAULT_REMOTE_BRANCH: &str = "origin/master"; - fn get_options_and_packages( - repo: &Repository, + repo: &common::repository::Repository, overrides: Option, extra_cli: Option, ) -> ( @@ -33,7 +31,7 @@ fn get_options_and_packages( (parsed.options, package_infos, scoped_packages) } -fn check_out_test_branch(repo: &Repository, name: &str) { +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]); } @@ -204,40 +202,28 @@ fn excludes_packages_with_existing_change_files() { #[test] fn ignores_package_changes_as_appropriate() { - use serde_json::json; - - let mut packages: HashMap = HashMap::new(); - packages.insert( - "private-pkg".to_string(), - json!({ - "name": "private-pkg", "version": "1.0.0", "private": true - }), - ); - packages.insert( - "no-publish".to_string(), - json!({ - "name": "no-publish", "version": "1.0.0", - "beachball": { "shouldPublish": false } - }), - ); - packages.insert( - "out-of-scope".to_string(), - json!({ - "name": "out-of-scope", "version": "1.0.0" - }), - ); - packages.insert( - "ignore-pkg".to_string(), - json!({ - "name": "ignore-pkg", "version": "1.0.0" - }), - ); - packages.insert( - "publish-me".to_string(), - json!({ - "name": "publish-me", "version": "1.0.0" - }), - ); + 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", diff --git a/rust/tests/common/fixtures.rs b/rust/tests/common/fixtures.rs index 6a4fc512c..34e455c82 100644 --- a/rust/tests/common/fixtures.rs +++ b/rust/tests/common/fixtures.rs @@ -7,42 +7,11 @@ pub type FixtureResult = ( Vec<(String, HashMap)>, ); -/// Package definition for a fixture. -pub struct PackageFixture { - pub name: Option, - pub version: String, - pub private: Option, - pub dependencies: Option>, - pub beachball: Option, - pub other: Option, -} - -impl PackageFixture { - pub fn to_json(&self, default_name: &str) -> serde_json::Value { - let mut obj = serde_json::Map::new(); - obj.insert( - "name".to_string(), - json!(self.name.as_deref().unwrap_or(default_name)), - ); - obj.insert("version".to_string(), json!(self.version)); - if let Some(private) = self.private { - obj.insert("private".to_string(), json!(private)); - } - if let Some(ref deps) = self.dependencies { - obj.insert("dependencies".to_string(), json!(deps)); - } - if let Some(ref bb) = self.beachball { - obj.insert("beachball".to_string(), bb.clone()); - } - if let Some(ref other) = self.other - && let serde_json::Value::Object(map) = other - { - for (k, v) in map { - obj.insert(k.clone(), v.clone()); - } - } - serde_json::Value::Object(obj) - } +/// 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. @@ -58,79 +27,24 @@ pub fn single_package_fixture() -> FixtureResult { (root, vec![]) } -/// Fixture for a monorepo. +/// Fixture for a monorepo, optionally scoped (e.g. "@project-a/foo"). pub fn monorepo_fixture() -> FixtureResult { - let root = json!({ - "name": "monorepo-fixture", - "version": "1.0.0", - "private": true, - "workspaces": ["packages/*", "packages/grouped/*"], - "beachball": { - "groups": [{ - "disallowedChangeTypes": null, - "name": "grouped", - "include": "group*" - }] - } - }); - - let packages: HashMap = HashMap::from([ - ( - "foo".to_string(), - json!({ - "name": "foo", - "version": "1.0.0", - "dependencies": { "bar": "^1.3.4" }, - "main": "src/index.ts" - }), - ), - ( - "bar".to_string(), - json!({ - "name": "bar", - "version": "1.3.4", - "dependencies": { "baz": "^1.3.4" } - }), - ), - ( - "baz".to_string(), - json!({ - "name": "baz", - "version": "1.3.4" - }), - ), - ]); - - let grouped: HashMap = HashMap::from([ - ( - "a".to_string(), - json!({ - "name": "a", - "version": "3.1.2" - }), - ), - ( - "b".to_string(), - json!({ - "name": "b", - "version": "3.1.2" - }), - ), - ]); - - ( - root, - vec![ - ("packages".to_string(), packages), - ("packages/grouped".to_string(), grouped), - ], - ) + 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": format!("@{scope}/monorepo-fixture"), + "name": name("monorepo-fixture"), "version": "1.0.0", "private": true, "workspaces": ["packages/*", "packages/grouped/*"], @@ -143,49 +57,28 @@ pub fn scoped_monorepo_fixture(scope: &str) -> FixtureResult { } }); - let packages: HashMap = HashMap::from([ - ( - "foo".to_string(), - json!({ - "name": format!("@{scope}/foo"), - "version": "1.0.0", - "dependencies": { format!("@{scope}/bar"): "^1.3.4" }, - "main": "src/index.ts" - }), - ), - ( - "bar".to_string(), - json!({ - "name": format!("@{scope}/bar"), - "version": "1.3.4", - "dependencies": { format!("@{scope}/baz"): "^1.3.4" } - }), - ), - ( - "baz".to_string(), - json!({ - "name": format!("@{scope}/baz"), - "version": "1.3.4" - }), - ), - ]); + 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: HashMap = HashMap::from([ - ( - "a".to_string(), - json!({ - "name": format!("@{scope}/a"), - "version": "3.1.2" - }), - ), - ( - "b".to_string(), - json!({ - "name": format!("@{scope}/b"), - "version": "3.1.2" - }), - ), - ]); + let grouped = packages! { + "a" => json!({ "name": name("a"), "version": "3.1.2" }), + "b" => json!({ "name": name("b"), "version": "3.1.2" }), + }; ( root, diff --git a/rust/tests/common/mod.rs b/rust/tests/common/mod.rs index a067c89e1..ec82d324d 100644 --- a/rust/tests/common/mod.rs +++ b/rust/tests/common/mod.rs @@ -6,3 +6,43 @@ pub mod fixtures; pub mod repository; #[allow(dead_code)] pub mod repository_factory; + +use std::process::Command; + +use beachball::options::get_options::get_parsed_options_for_test; +use beachball::types::options::{BeachballOptions, CliOptions}; + +#[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() +} + +/// 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 index 992618a4e..f59bd9bdf 100644 --- a/rust/tests/common/repository.rs +++ b/rust/tests/common/repository.rs @@ -1,6 +1,7 @@ use std::fs; use std::path::PathBuf; -use std::process::Command; + +use super::run_git; /// A test git repository (cloned from a bare origin). pub struct Repository { @@ -105,21 +106,3 @@ impl Drop for Repository { self.clean_up(); } } - -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); - // Don't panic on expected failures - 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() -} diff --git a/rust/tests/common/repository_factory.rs b/rust/tests/common/repository_factory.rs index ab7115166..b6d88ac25 100644 --- a/rust/tests/common/repository_factory.rs +++ b/rust/tests/common/repository_factory.rs @@ -1,10 +1,10 @@ use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; -use std::process::Command; use super::fixtures; use super::repository::Repository; +use super::run_git; /// Creates a bare "origin" repo and provides cloning for tests. pub struct RepositoryFactory { @@ -221,20 +221,3 @@ fn set_default_branch(bare_repo_path: &Path) { bare_repo_path.to_str().unwrap(), ); } - -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() -} diff --git a/rust/tests/disallowed_change_types_test.rs b/rust/tests/disallowed_change_types_test.rs index 86eb8a9ae..0bebd7675 100644 --- a/rust/tests/disallowed_change_types_test.rs +++ b/rust/tests/disallowed_change_types_test.rs @@ -9,12 +9,17 @@ fn make_info(name: &str) -> PackageInfo { name: name.to_string(), package_json_path: format!("/fake/{name}/package.json"), version: "1.0.0".to_string(), - private: false, - package_options: None, - dependencies: None, - dev_dependencies: None, - peer_dependencies: None, - optional_dependencies: None, + ..Default::default() + } +} + +fn make_info_with_disallowed(name: &str, disallowed: Vec) -> PackageInfo { + PackageInfo { + package_options: Some(PackageOptions { + disallowed_change_types: Some(disallowed), + ..Default::default() + }), + ..make_info(name) } } @@ -40,15 +45,10 @@ fn falls_back_to_repo_option() { #[test] fn returns_package_level_disallowed() { let mut infos = PackageInfos::new(); - let mut info = make_info("foo"); - info.package_options = Some(PackageOptions { - disallowed_change_types: Some(vec![ChangeType::Major, ChangeType::Minor]), - tag: None, - default_npm_tag: None, - git_tags: None, - should_publish: None, - }); - infos.insert("foo".to_string(), info); + infos.insert( + "foo".to_string(), + make_info_with_disallowed("foo", vec![ChangeType::Major, ChangeType::Minor]), + ); let groups = PackageGroups::new(); let result = get_disallowed_change_types("foo", &infos, &groups, &None); @@ -76,15 +76,10 @@ fn returns_group_level_disallowed() { #[test] fn returns_package_level_if_not_in_group() { let mut infos = PackageInfos::new(); - let mut info = make_info("foo"); - info.package_options = Some(PackageOptions { - disallowed_change_types: Some(vec![ChangeType::Minor]), - tag: None, - default_npm_tag: None, - git_tags: None, - should_publish: None, - }); - infos.insert("foo".to_string(), info); + infos.insert( + "foo".to_string(), + make_info_with_disallowed("foo", vec![ChangeType::Minor]), + ); let mut groups = PackageGroups::new(); groups.insert( @@ -102,15 +97,10 @@ fn returns_package_level_if_not_in_group() { #[test] fn prefers_group_over_package() { let mut infos = PackageInfos::new(); - let mut info = make_info("foo"); - info.package_options = Some(PackageOptions { - disallowed_change_types: Some(vec![ChangeType::Minor]), - tag: None, - default_npm_tag: None, - git_tags: None, - should_publish: None, - }); - infos.insert("foo".to_string(), info); + infos.insert( + "foo".to_string(), + make_info_with_disallowed("foo", vec![ChangeType::Minor]), + ); let mut groups = PackageGroups::new(); groups.insert( diff --git a/rust/tests/package_groups_test.rs b/rust/tests/package_groups_test.rs index 215ac9835..bcd4bd2ad 100644 --- a/rust/tests/package_groups_test.rs +++ b/rust/tests/package_groups_test.rs @@ -2,24 +2,18 @@ use beachball::monorepo::package_groups::get_package_groups; use beachball::types::options::{VersionGroupInclude, VersionGroupOptions}; use beachball::types::package_info::{PackageInfo, PackageInfos}; -fn make_info(name: &str, root: &str, folder: &str) -> PackageInfo { - PackageInfo { - name: name.to_string(), - package_json_path: format!("{}/{}/package.json", root, folder), - version: "1.0.0".to_string(), - private: false, - package_options: None, - dependencies: None, - dev_dependencies: None, - peer_dependencies: None, - optional_dependencies: None, - } -} - fn make_infos(packages: &[(&str, &str)], root: &str) -> PackageInfos { let mut infos = PackageInfos::new(); for (name, folder) in packages { - infos.insert(name.to_string(), make_info(name, root, folder)); + infos.insert( + name.to_string(), + PackageInfo { + name: name.to_string(), + package_json_path: format!("{root}/{folder}/package.json"), + version: "1.0.0".to_string(), + ..Default::default() + }, + ); } infos } @@ -233,6 +227,5 @@ fn omits_empty_groups() { }]); let result = get_package_groups(&infos, ROOT, &groups).unwrap(); - // The group exists but has no packages assert!(result["empty"].package_names.is_empty()); } diff --git a/rust/tests/validate_test.rs b/rust/tests/validate_test.rs index b39d96ef3..6160c73bc 100644 --- a/rust/tests/validate_test.rs +++ b/rust/tests/validate_test.rs @@ -3,27 +3,20 @@ 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; -const DEFAULT_REMOTE_BRANCH: &str = "origin/master"; - -fn make_test_options() -> (CliOptions, BeachballOptions) { - let cli = CliOptions::default(); +fn validate_wrapper( + repo: &Repository, + validate_options: ValidateOptions, +) -> Result { let repo_opts = BeachballOptions { branch: DEFAULT_REMOTE_BRANCH.to_string(), fetch: false, ..Default::default() }; - (cli, repo_opts) -} - -fn validate_wrapper( - repo: &Repository, - validate_options: ValidateOptions, -) -> Result { - let (cli, repo_opts) = make_test_options(); - let parsed = get_parsed_options_for_test(repo.root_path(), cli, repo_opts); + let parsed = get_parsed_options_for_test(repo.root_path(), CliOptions::default(), repo_opts); validate(&parsed, &validate_options) } @@ -42,8 +35,7 @@ fn succeeds_with_no_changes() { ); assert!(result.is_ok()); - let result = result.unwrap(); - assert!(!result.is_change_needed); + assert!(!result.unwrap().is_change_needed); } #[test] @@ -82,6 +74,5 @@ fn returns_without_error_if_allow_missing_change_files() { ); assert!(result.is_ok()); - let result = result.unwrap(); - assert!(result.is_change_needed); + assert!(result.unwrap().is_change_needed); } diff --git a/rust/tests/write_change_files_test.rs b/rust/tests/write_change_files_test.rs index ab88aa253..b2947922e 100644 --- a/rust/tests/write_change_files_test.rs +++ b/rust/tests/write_change_files_test.rs @@ -1,23 +1,10 @@ mod common; use beachball::changefile::write_change_files::write_change_files; -use beachball::options::get_options::get_parsed_options_for_test; use beachball::types::change_info::{ChangeFileInfo, ChangeType}; -use beachball::types::options::{BeachballOptions, CliOptions}; +use beachball::types::options::BeachballOptions; use common::repository_factory::RepositoryFactory; - -const DEFAULT_BRANCH: &str = "master"; -const DEFAULT_REMOTE_BRANCH: &str = "origin/master"; - -fn make_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 -} +use common::{DEFAULT_BRANCH, make_test_options}; fn make_changes() -> Vec { vec![ @@ -44,13 +31,12 @@ fn writes_individual_change_files() { let repo = factory.clone_repository(); repo.checkout(&["-b", "test", DEFAULT_BRANCH]); - let options = make_options(repo.root_path(), None); + 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); - // Verify files exist on disk for path in &result { assert!( std::path::Path::new(path).exists(), @@ -77,13 +63,12 @@ fn respects_change_dir_option() { ..Default::default() }; - let options = make_options(repo.root_path(), Some(custom_opts)); + 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); - // Verify files are in the custom directory for path in &result { assert!( path.contains("customChangeDir"), @@ -102,7 +87,6 @@ fn respects_commit_false() { let repo = factory.clone_repository(); repo.checkout(&["-b", "test", DEFAULT_BRANCH]); - // Get current HEAD hash before writing let hash_before = repo.git(&["rev-parse", "HEAD"]); let no_commit_opts = BeachballOptions { @@ -110,13 +94,12 @@ fn respects_commit_false() { ..Default::default() }; - let options = make_options(repo.root_path(), Some(no_commit_opts)); + 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); - // Verify files exist on disk for path in &result { assert!( std::path::Path::new(path).exists(), @@ -124,7 +107,6 @@ fn respects_commit_false() { ); } - // Verify no new commit was created let hash_after = repo.git(&["rev-parse", "HEAD"]); assert_eq!( hash_before, hash_after, From 312c8f5c72e1b1a8025719ff8c17837f7446a4d9 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Fri, 6 Mar 2026 14:33:58 -0800 Subject: [PATCH 05/38] pin actions --- .github/workflows/pr.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 27158d067..ff0e61970 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -99,12 +99,12 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable with: components: clippy, rustfmt - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: | ~/.cargo/registry @@ -142,7 +142,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 with: go-version-file: go/go.mod cache-dependency-path: go/go.sum From 9293c6beced5b7010f3bb5344b8c733d37b20933 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Fri, 6 Mar 2026 14:46:18 -0800 Subject: [PATCH 06/38] add missing change impl and tests --- .github/workflows/pr.yml | 1 + go/cmd/beachball/main.go | 13 + go/internal/changefile/write_change_files.go | 30 +- go/internal/commands/change.go | 13 +- go/internal/commands/change_test.go | 279 +++++++++++++++++++ go/internal/options/get_options.go | 3 + rust/tests/change_test.rs | 230 +++++++++++++-- 7 files changed, 541 insertions(+), 28 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ff0e61970..36db912be 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -149,6 +149,7 @@ jobs: - name: Format run: test -z "$(gofmt -l .)" + shell: bash working-directory: ./go - name: Lint diff --git a/go/cmd/beachball/main.go b/go/cmd/beachball/main.go index 8d5ad524c..4758d3e65 100644 --- a/go/cmd/beachball/main.go +++ b/go/cmd/beachball/main.go @@ -54,6 +54,13 @@ func main() { } changeCmd.Flags().StringVarP(&cli.ChangeType, "type", "t", "", "change type (patch, minor, major, etc.)") changeCmd.Flags().StringVarP(&cli.Message, "message", "m", "", "change description") + 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") @@ -66,6 +73,12 @@ func main() { 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) diff --git a/go/internal/changefile/write_change_files.go b/go/internal/changefile/write_change_files.go index 0f9e3eb53..8f59acc7f 100644 --- a/go/internal/changefile/write_change_files.go +++ b/go/internal/changefile/write_change_files.go @@ -23,15 +23,16 @@ func WriteChangeFiles(options *types.BeachballOptions, changes []types.ChangeFil var filePaths []string - for _, change := range changes { + if options.GroupChanges { + // Write all changes to a single grouped file id := uuid.New().String() - sanitized := nonAlphanumRe.ReplaceAllString(change.PackageName, "-") - filename := fmt.Sprintf("%s-%s.json", sanitized, id) + filename := fmt.Sprintf("change-%s.json", id) filePath := filepath.Join(changePath, filename) - data, err := json.MarshalIndent(change, "", " ") + grouped := types.ChangeInfoMultiple{Changes: changes} + data, err := json.MarshalIndent(grouped, "", " ") if err != nil { - return fmt.Errorf("failed to marshal change: %w", err) + return fmt.Errorf("failed to marshal grouped changes: %w", err) } if err := os.WriteFile(filePath, append(data, '\n'), 0o644); err != nil { @@ -40,6 +41,25 @@ func WriteChangeFiles(options *types.BeachballOptions, changes []types.ChangeFil filePaths = append(filePaths, filePath) fmt.Printf("Wrote change file: %s\n", filename) + } 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) + fmt.Printf("Wrote change file: %s\n", filename) + } } if len(filePaths) > 0 { diff --git a/go/internal/commands/change.go b/go/internal/commands/change.go index da25a1bb4..aa71c050c 100644 --- a/go/internal/commands/change.go +++ b/go/internal/commands/change.go @@ -19,7 +19,7 @@ func Change(parsed types.ParsedOptions) error { return err } - if !result.IsChangeNeeded { + if !result.IsChangeNeeded && len(parsed.Options.Package) == 0 { fmt.Println("No changes detected; no change files are needed.") return nil } @@ -46,8 +46,17 @@ func Change(parsed types.ParsedOptions) error { } } + 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 result.ChangedPackages { + for _, pkg := range changedPackages { changes = append(changes, types.ChangeFileInfo{ Type: changeType, Comment: message, diff --git a/go/internal/commands/change_test.go b/go/internal/commands/change_test.go index 89457ce3b..b5517b68c 100644 --- a/go/internal/commands/change_test.go +++ b/go/internal/commands/change_test.go @@ -3,6 +3,7 @@ package commands_test import ( "encoding/json" "os" + "strings" "testing" "github.com/microsoft/beachball/internal/commands" @@ -94,3 +95,281 @@ func TestCreatesChangeFileWithTypeAndMessage(t *testing.T) { t.Fatalf("expected patch dependent type, got %s", change.DependentChangeType) } } + +func TestCreatesAndStagesChangeFile(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "single") + repo := factory.CloneRepository() + repo.Checkout("-b", "stages-change-test", defaultBranch) + repo.CommitChange("file.js") + + repoOpts := types.DefaultOptions() + repoOpts.Branch = defaultRemoteBranch + repoOpts.Fetch = false + 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) + if err := commands.Change(parsed); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify file is staged (git status shows "A ") + status := repo.Status() + if !strings.Contains(status, "A ") { + t.Fatalf("expected staged file (A prefix), got status: %q", status) + } + + files := testutil.GetChangeFiles(&parsed.Options) + if len(files) != 1 { + t.Fatalf("expected 1 change file, got %d", len(files)) + } + + data, _ := os.ReadFile(files[0]) + var change types.ChangeFileInfo + json.Unmarshal(data, &change) + if change.Comment != "stage me please" { + t.Fatalf("expected 'stage me please', got %q", change.Comment) + } + if change.PackageName != "foo" { + t.Fatalf("expected 'foo', got %q", change.PackageName) + } +} + +func TestCreatesAndCommitsChangeFile(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "single") + repo := factory.CloneRepository() + repo.Checkout("-b", "commits-change-test", defaultBranch) + repo.CommitChange("file.js") + + repoOpts := types.DefaultOptions() + repoOpts.Branch = defaultRemoteBranch + repoOpts.Fetch = false + + cli := types.CliOptions{ + Command: "change", + Message: "commit me please", + ChangeType: "patch", + } + + parsed := options.GetParsedOptionsForTest(repo.RootPath(), cli, repoOpts) + if err := commands.Change(parsed); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify clean git status (committed) + status := repo.Status() + if status != "" { + t.Fatalf("expected clean status after commit, got: %q", status) + } + + files := testutil.GetChangeFiles(&parsed.Options) + if len(files) != 1 { + t.Fatalf("expected 1 change file, got %d", len(files)) + } + + data, _ := os.ReadFile(files[0]) + var change types.ChangeFileInfo + json.Unmarshal(data, &change) + if change.Comment != "commit me please" { + t.Fatalf("expected 'commit me please', got %q", change.Comment) + } +} + +func TestCreatesAndCommitsChangeFileWithChangeDir(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "single") + repo := factory.CloneRepository() + repo.Checkout("-b", "changedir-test", defaultBranch) + repo.CommitChange("file.js") + + repoOpts := types.DefaultOptions() + repoOpts.Branch = defaultRemoteBranch + repoOpts.Fetch = false + repoOpts.ChangeDir = "changeDir" + + cli := types.CliOptions{ + Command: "change", + Message: "commit me please", + ChangeType: "patch", + } + + parsed := options.GetParsedOptionsForTest(repo.RootPath(), cli, repoOpts) + if err := commands.Change(parsed); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + status := repo.Status() + if status != "" { + t.Fatalf("expected clean status after commit, got: %q", status) + } + + files := testutil.GetChangeFiles(&parsed.Options) + if len(files) != 1 { + t.Fatalf("expected 1 change file, got %d", len(files)) + } + + // Verify file is in custom directory + if !strings.Contains(files[0], "changeDir") { + t.Fatalf("expected file in changeDir, got: %s", files[0]) + } + + data, _ := os.ReadFile(files[0]) + var change types.ChangeFileInfo + json.Unmarshal(data, &change) + if change.Comment != "commit me please" { + t.Fatalf("expected 'commit me please', got %q", change.Comment) + } +} + +func TestCreatesChangeFileWhenNoChangesButPackageProvided(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "single") + repo := factory.CloneRepository() + repo.Checkout("-b", "package-flag-test", defaultBranch) + + repoOpts := types.DefaultOptions() + repoOpts.Branch = defaultRemoteBranch + repoOpts.Fetch = false + 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) + if err := commands.Change(parsed); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + files := testutil.GetChangeFiles(&parsed.Options) + if len(files) != 1 { + t.Fatalf("expected 1 change file, got %d", len(files)) + } + + data, _ := os.ReadFile(files[0]) + var change types.ChangeFileInfo + json.Unmarshal(data, &change) + if change.PackageName != "foo" { + t.Fatalf("expected 'foo', got %q", change.PackageName) + } +} + +func TestCreatesAndCommitsChangeFilesForMultiplePackages(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + repo.Checkout("-b", "multi-pkg-test", defaultBranch) + repo.CommitChange("packages/foo/file.js") + repo.CommitChange("packages/bar/file.js") + + repoOpts := types.DefaultOptions() + repoOpts.Branch = defaultRemoteBranch + repoOpts.Fetch = false + + cli := types.CliOptions{ + Command: "change", + Message: "multi-package change", + ChangeType: "minor", + } + + parsed := options.GetParsedOptionsForTest(repo.RootPath(), cli, repoOpts) + if err := commands.Change(parsed); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + status := repo.Status() + if status != "" { + t.Fatalf("expected clean status, got: %q", status) + } + + files := testutil.GetChangeFiles(&parsed.Options) + if len(files) != 2 { + t.Fatalf("expected 2 change files, got %d", len(files)) + } + + packageNames := map[string]bool{} + for _, f := range files { + data, _ := os.ReadFile(f) + var change types.ChangeFileInfo + json.Unmarshal(data, &change) + packageNames[change.PackageName] = true + if change.Type != types.ChangeTypeMinor { + t.Fatalf("expected minor, got %s for %s", change.Type, change.PackageName) + } + if change.Comment != "multi-package change" { + t.Fatalf("expected 'multi-package change', got %q", change.Comment) + } + } + + if !packageNames["foo"] || !packageNames["bar"] { + t.Fatalf("expected foo and bar, got %v", packageNames) + } +} + +func TestCreatesAndCommitsGroupedChangeFile(t *testing.T) { + factory := testutil.NewRepositoryFactory(t, "monorepo") + repo := factory.CloneRepository() + repo.Checkout("-b", "grouped-test", defaultBranch) + repo.CommitChange("packages/foo/file.js") + repo.CommitChange("packages/bar/file.js") + + repoOpts := types.DefaultOptions() + repoOpts.Branch = defaultRemoteBranch + repoOpts.Fetch = false + repoOpts.GroupChanges = true + + cli := types.CliOptions{ + Command: "change", + Message: "grouped change", + ChangeType: "minor", + } + + parsed := options.GetParsedOptionsForTest(repo.RootPath(), cli, repoOpts) + if err := commands.Change(parsed); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + status := repo.Status() + if status != "" { + t.Fatalf("expected clean status, got: %q", status) + } + + files := testutil.GetChangeFiles(&parsed.Options) + if len(files) != 1 { + t.Fatalf("expected 1 grouped change file, got %d", len(files)) + } + + data, _ := os.ReadFile(files[0]) + var grouped types.ChangeInfoMultiple + if err := json.Unmarshal(data, &grouped); err != nil { + t.Fatalf("failed to parse grouped change file: %v", err) + } + + if len(grouped.Changes) != 2 { + t.Fatalf("expected 2 changes in grouped file, got %d", len(grouped.Changes)) + } + + packageNames := map[string]bool{} + for _, change := range grouped.Changes { + packageNames[change.PackageName] = true + if change.Type != types.ChangeTypeMinor { + t.Fatalf("expected minor, got %s for %s", change.Type, change.PackageName) + } + if change.Comment != "grouped change" { + t.Fatalf("expected 'grouped change', got %q", change.Comment) + } + } + + if !packageNames["foo"] || !packageNames["bar"] { + t.Fatalf("expected foo and bar, got %v", packageNames) + } +} diff --git a/go/internal/options/get_options.go b/go/internal/options/get_options.go index 7041ba437..34eceab0a 100644 --- a/go/internal/options/get_options.go +++ b/go/internal/options/get_options.go @@ -81,6 +81,9 @@ func GetParsedOptionsForTest(cwd string, cli types.CliOptions, repoOpts types.Be if repoOpts.Groups != nil { opts.Groups = repoOpts.Groups } + if repoOpts.GroupChanges { + opts.GroupChanges = true + } // Apply CLI overrides applyCliOptions(&opts, &cli) diff --git a/rust/tests/change_test.rs b/rust/tests/change_test.rs index 5eb0280bf..f49cee729 100644 --- a/rust/tests/change_test.rs +++ b/rust/tests/change_test.rs @@ -2,31 +2,37 @@ mod common; use beachball::commands::change::change; use beachball::options::get_options::get_parsed_options_for_test; -use beachball::types::change_info::{ChangeFileInfo, ChangeType}; +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}; +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() + } +} + #[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]); - let repo_opts = BeachballOptions { - branch: DEFAULT_REMOTE_BRANCH.to_string(), - fetch: false, - ..Default::default() - }; - let cli = CliOptions { - command: Some("change".to_string()), - message: Some("test change".to_string()), - change_type: Some(ChangeType::Patch), - ..Default::default() - }; - - let parsed = get_parsed_options_for_test(repo.root_path(), cli, repo_opts); + 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()); assert!(get_change_files(&parsed.options).is_empty()); } @@ -39,17 +45,12 @@ fn creates_change_file_with_type_and_message() { repo.commit_change("file.js"); let repo_opts = BeachballOptions { - branch: DEFAULT_REMOTE_BRANCH.to_string(), - fetch: false, commit: false, - ..Default::default() + ..make_repo_opts() }; let cli = CliOptions { - command: Some("change".to_string()), - message: Some("test description".to_string()), - change_type: Some(ChangeType::Patch), commit: Some(false), - ..Default::default() + ..make_cli("test description", ChangeType::Patch) }; let parsed = get_parsed_options_for_test(repo.root_path(), cli, repo_opts); @@ -65,3 +66,190 @@ fn creates_change_file_with_type_and_message() { assert_eq!(change.package_name, "foo"); assert_eq!(change.dependent_change_type, ChangeType::Patch); } + +#[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) + }; + + let parsed = get_parsed_options_for_test(repo.root_path(), cli, repo_opts); + assert!(change(&parsed).is_ok()); + + // 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"); +} + +#[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"); + + 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()); + + // 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"); +} + +#[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"); +} + +#[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"); +} + +#[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"]); +} + +#[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"]); +} From e1c792f20037953991b336eff5ea4df0e0e1fdc2 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Fri, 6 Mar 2026 14:59:59 -0800 Subject: [PATCH 07/38] format ubuntu only --- .github/workflows/pr.yml | 14 ++++++++++++-- .prettierignore | 1 + package.json | 3 ++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 36db912be..e47a2fef4 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 @@ -115,6 +118,7 @@ jobs: - name: Format run: cargo fmt --check working-directory: ./rust + if: ${{ matrix.os == 'ubuntu-latest' }} - name: Lint run: cargo clippy --all-targets -- -D warnings @@ -148,9 +152,15 @@ jobs: cache-dependency-path: go/go.sum - name: Format - run: test -z "$(gofmt -l .)" - shell: bash + 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: Lint run: go vet ./... diff --git a/.prettierignore b/.prettierignore index aaa384d36..b44c7267f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,6 +8,7 @@ .nojekyll .nvmrc docs/.vuepress/dist/ +**/.yarn /change/ /CHANGELOG.* /lib/ diff --git a/package.json b/package.json index c437deb44..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", From 3844355fd843fd3c9f8472565ea77146fc50ebec Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Fri, 6 Mar 2026 15:44:28 -0800 Subject: [PATCH 08/38] Update package_groups.go with the latest content --- go/internal/monorepo/package_groups.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/go/internal/monorepo/package_groups.go b/go/internal/monorepo/package_groups.go index 4198d1667..69224052b 100644 --- a/go/internal/monorepo/package_groups.go +++ b/go/internal/monorepo/package_groups.go @@ -23,6 +23,8 @@ func GetPackageGroups(packageInfos types.PackageInfos, rootPath string, groups [ if err != nil { continue } + // Normalize to forward slashes for cross-platform glob matching + relPath = filepath.ToSlash(relPath) included := false for _, pattern := range g.Include { @@ -53,4 +55,4 @@ func GetPackageGroups(packageInfos types.PackageInfos, rootPath string, groups [ } return result -} +} \ No newline at end of file From ffadef3f195796c51b3be2911ad9594404f10d1c Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Fri, 6 Mar 2026 16:09:52 -0800 Subject: [PATCH 09/38] Update scoped_packages.go with new content --- go/internal/monorepo/scoped_packages.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/go/internal/monorepo/scoped_packages.go b/go/internal/monorepo/scoped_packages.go index e789528ef..cd53c70da 100644 --- a/go/internal/monorepo/scoped_packages.go +++ b/go/internal/monorepo/scoped_packages.go @@ -38,6 +38,8 @@ func GetScopedPackages(options *types.BeachballOptions, packageInfos types.Packa if err != nil { continue } + // Normalize to forward slashes for cross-platform glob matching + relPath = filepath.ToSlash(relPath) matched, _ := doublestar.PathMatch(cleanPattern, relPath) if !matched { @@ -78,4 +80,4 @@ func GetScopedPackages(options *types.BeachballOptions, packageInfos types.Packa } return scoped -} +} \ No newline at end of file From d8f4875c7e467a0431eae4bd75263620054a497e Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Fri, 6 Mar 2026 16:23:40 -0800 Subject: [PATCH 10/38] test path updates --- go/internal/changefile/change_types_test.go | 46 +++--------- go/internal/monorepo/package_groups.go | 2 +- go/internal/monorepo/package_groups_test.go | 46 +++++------- go/internal/monorepo/scoped_packages.go | 2 +- go/internal/testutil/package_infos.go | 39 ++++++++++ rust/tests/common/mod.rs | 51 +++++++++++++ rust/tests/disallowed_change_types_test.rs | 53 +++++--------- rust/tests/package_groups_test.rs | 81 ++++++++++----------- 8 files changed, 177 insertions(+), 143 deletions(-) create mode 100644 go/internal/testutil/package_infos.go diff --git a/go/internal/changefile/change_types_test.go b/go/internal/changefile/change_types_test.go index c6822c215..5842dc7f7 100644 --- a/go/internal/changefile/change_types_test.go +++ b/go/internal/changefile/change_types_test.go @@ -4,9 +4,12 @@ import ( "testing" "github.com/microsoft/beachball/internal/changefile" + "github.com/microsoft/beachball/internal/testutil" "github.com/microsoft/beachball/internal/types" ) +var testRoot = testutil.FakeRoot() + func TestGetDisallowedChangeTypes_ReturnsNilForUnknownPackage(t *testing.T) { infos := types.PackageInfos{} groups := types.PackageGroups{} @@ -19,12 +22,7 @@ func TestGetDisallowedChangeTypes_ReturnsNilForUnknownPackage(t *testing.T) { } func TestGetDisallowedChangeTypes_ReturnsNilWhenNoSettings(t *testing.T) { - infos := types.PackageInfos{ - "foo": &types.PackageInfo{ - Name: "foo", - Version: "1.0.0", - }, - } + infos := testutil.MakePackageInfosSimple(testRoot, "foo") groups := types.PackageGroups{} opts := &types.BeachballOptions{} @@ -35,14 +33,9 @@ func TestGetDisallowedChangeTypes_ReturnsNilWhenNoSettings(t *testing.T) { } func TestGetDisallowedChangeTypes_ReturnsPackageLevelDisallowedTypes(t *testing.T) { - infos := types.PackageInfos{ - "foo": &types.PackageInfo{ - Name: "foo", - Version: "1.0.0", - PackageOptions: &types.PackageOptions{ - DisallowedChangeTypes: []string{"major"}, - }, - }, + infos := testutil.MakePackageInfosSimple(testRoot, "foo") + infos["foo"].PackageOptions = &types.PackageOptions{ + DisallowedChangeTypes: []string{"major"}, } groups := types.PackageGroups{} opts := &types.BeachballOptions{} @@ -54,12 +47,7 @@ func TestGetDisallowedChangeTypes_ReturnsPackageLevelDisallowedTypes(t *testing. } func TestGetDisallowedChangeTypes_ReturnsGroupLevelDisallowedTypes(t *testing.T) { - infos := types.PackageInfos{ - "foo": &types.PackageInfo{ - Name: "foo", - Version: "1.0.0", - }, - } + infos := testutil.MakePackageInfosSimple(testRoot, "foo") groups := types.PackageGroups{ "grp1": &types.PackageGroup{ Name: "grp1", @@ -76,12 +64,7 @@ func TestGetDisallowedChangeTypes_ReturnsGroupLevelDisallowedTypes(t *testing.T) } func TestGetDisallowedChangeTypes_ReturnsNilIfNotInGroup(t *testing.T) { - infos := types.PackageInfos{ - "bar": &types.PackageInfo{ - Name: "bar", - Version: "1.0.0", - }, - } + infos := testutil.MakePackageInfosSimple(testRoot, "bar") groups := types.PackageGroups{ "grp1": &types.PackageGroup{ Name: "grp1", @@ -98,14 +81,9 @@ func TestGetDisallowedChangeTypes_ReturnsNilIfNotInGroup(t *testing.T) { } func TestGetDisallowedChangeTypes_PrefersPackageOverGroup(t *testing.T) { - infos := types.PackageInfos{ - "foo": &types.PackageInfo{ - Name: "foo", - Version: "1.0.0", - PackageOptions: &types.PackageOptions{ - DisallowedChangeTypes: []string{"major"}, - }, - }, + infos := testutil.MakePackageInfosSimple(testRoot, "foo") + infos["foo"].PackageOptions = &types.PackageOptions{ + DisallowedChangeTypes: []string{"major"}, } groups := types.PackageGroups{ "grp1": &types.PackageGroup{ diff --git a/go/internal/monorepo/package_groups.go b/go/internal/monorepo/package_groups.go index 69224052b..8fc9bc950 100644 --- a/go/internal/monorepo/package_groups.go +++ b/go/internal/monorepo/package_groups.go @@ -55,4 +55,4 @@ func GetPackageGroups(packageInfos types.PackageInfos, rootPath string, groups [ } return result -} \ No newline at end of file +} diff --git a/go/internal/monorepo/package_groups_test.go b/go/internal/monorepo/package_groups_test.go index 12a0f2f4f..b2778b6e2 100644 --- a/go/internal/monorepo/package_groups_test.go +++ b/go/internal/monorepo/package_groups_test.go @@ -1,38 +1,28 @@ package monorepo_test import ( - "path/filepath" "sort" "testing" "github.com/microsoft/beachball/internal/monorepo" + "github.com/microsoft/beachball/internal/testutil" "github.com/microsoft/beachball/internal/types" ) -func makeInfos(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 -} +var root = testutil.FakeRoot() func TestGetPackageGroups_ReturnsEmptyIfNoGroups(t *testing.T) { - infos := makeInfos("/repo", map[string]string{ + infos := testutil.MakePackageInfos(root, map[string]string{ "packages/foo": "foo", }) - result := monorepo.GetPackageGroups(infos, "/repo", nil) + result := monorepo.GetPackageGroups(infos, root, nil) if len(result) != 0 { t.Fatalf("expected empty map, got: %v", result) } } func TestGetPackageGroups_ReturnsGroupsBasedOnSpecificFolders(t *testing.T) { - infos := makeInfos("/repo", map[string]string{ + infos := testutil.MakePackageInfos(root, map[string]string{ "packages/foo": "foo", "packages/bar": "bar", "packages/baz": "baz", @@ -43,7 +33,7 @@ func TestGetPackageGroups_ReturnsGroupsBasedOnSpecificFolders(t *testing.T) { Include: []string{"packages/foo", "packages/bar"}, }, } - result := monorepo.GetPackageGroups(infos, "/repo", groups) + result := monorepo.GetPackageGroups(infos, root, groups) if len(result) != 1 { t.Fatalf("expected 1 group, got %d", len(result)) } @@ -61,7 +51,7 @@ func TestGetPackageGroups_ReturnsGroupsBasedOnSpecificFolders(t *testing.T) { } func TestGetPackageGroups_HandlesSingleLevelGlobs(t *testing.T) { - infos := makeInfos("/repo", map[string]string{ + infos := testutil.MakePackageInfos(root, map[string]string{ "packages/ui-button": "ui-button", "packages/ui-input": "ui-input", "packages/core-utils": "core-utils", @@ -72,7 +62,7 @@ func TestGetPackageGroups_HandlesSingleLevelGlobs(t *testing.T) { Include: []string{"packages/ui-*"}, }, } - result := monorepo.GetPackageGroups(infos, "/repo", groups) + result := monorepo.GetPackageGroups(infos, root, groups) grp := result["ui"] if grp == nil { t.Fatal("expected ui group to exist") @@ -87,7 +77,7 @@ func TestGetPackageGroups_HandlesSingleLevelGlobs(t *testing.T) { } func TestGetPackageGroups_HandlesMultiLevelGlobs(t *testing.T) { - infos := makeInfos("/repo", map[string]string{ + infos := testutil.MakePackageInfos(root, map[string]string{ "packages/ui/button": "ui-button", "packages/ui/input": "ui-input", "packages/core": "core", @@ -98,7 +88,7 @@ func TestGetPackageGroups_HandlesMultiLevelGlobs(t *testing.T) { Include: []string{"packages/ui/**"}, }, } - result := monorepo.GetPackageGroups(infos, "/repo", groups) + result := monorepo.GetPackageGroups(infos, root, groups) grp := result["ui"] if grp == nil { t.Fatal("expected ui group to exist") @@ -113,7 +103,7 @@ func TestGetPackageGroups_HandlesMultiLevelGlobs(t *testing.T) { } func TestGetPackageGroups_HandlesMultipleIncludePatterns(t *testing.T) { - infos := makeInfos("/repo", map[string]string{ + infos := testutil.MakePackageInfos(root, map[string]string{ "packages/foo": "foo", "libs/bar": "bar", "other/baz": "baz", @@ -124,7 +114,7 @@ func TestGetPackageGroups_HandlesMultipleIncludePatterns(t *testing.T) { Include: []string{"packages/*", "libs/*"}, }, } - result := monorepo.GetPackageGroups(infos, "/repo", groups) + result := monorepo.GetPackageGroups(infos, root, groups) grp := result["mixed"] if grp == nil { t.Fatal("expected mixed group to exist") @@ -139,7 +129,7 @@ func TestGetPackageGroups_HandlesMultipleIncludePatterns(t *testing.T) { } func TestGetPackageGroups_HandlesExcludePatterns(t *testing.T) { - infos := makeInfos("/repo", map[string]string{ + infos := testutil.MakePackageInfos(root, map[string]string{ "packages/foo": "foo", "packages/bar": "bar", "packages/internal": "internal", @@ -151,7 +141,7 @@ func TestGetPackageGroups_HandlesExcludePatterns(t *testing.T) { Exclude: []string{"packages/internal"}, }, } - result := monorepo.GetPackageGroups(infos, "/repo", groups) + result := monorepo.GetPackageGroups(infos, root, groups) grp := result["public"] if grp == nil { t.Fatal("expected public group to exist") @@ -166,7 +156,7 @@ func TestGetPackageGroups_HandlesExcludePatterns(t *testing.T) { } func TestGetPackageGroups_HandlesGlobExclude(t *testing.T) { - infos := makeInfos("/repo", map[string]string{ + infos := testutil.MakePackageInfos(root, map[string]string{ "packages/ui/button": "ui-button", "packages/ui/input": "ui-input", "packages/core/utils": "core-utils", @@ -178,7 +168,7 @@ func TestGetPackageGroups_HandlesGlobExclude(t *testing.T) { Exclude: []string{"packages/core/*"}, }, } - result := monorepo.GetPackageGroups(infos, "/repo", groups) + result := monorepo.GetPackageGroups(infos, root, groups) grp := result["non-core"] if grp == nil { t.Fatal("expected non-core group to exist") @@ -193,7 +183,7 @@ func TestGetPackageGroups_HandlesGlobExclude(t *testing.T) { } func TestGetPackageGroups_OmitsEmptyGroups(t *testing.T) { - infos := makeInfos("/repo", map[string]string{ + infos := testutil.MakePackageInfos(root, map[string]string{ "packages/foo": "foo", }) groups := []types.VersionGroupOptions{ @@ -202,7 +192,7 @@ func TestGetPackageGroups_OmitsEmptyGroups(t *testing.T) { Include: []string{"nonexistent/*"}, }, } - result := monorepo.GetPackageGroups(infos, "/repo", groups) + result := monorepo.GetPackageGroups(infos, root, groups) grp := result["empty"] if grp == nil { t.Fatal("expected empty group key to exist") diff --git a/go/internal/monorepo/scoped_packages.go b/go/internal/monorepo/scoped_packages.go index cd53c70da..4d4225bdc 100644 --- a/go/internal/monorepo/scoped_packages.go +++ b/go/internal/monorepo/scoped_packages.go @@ -80,4 +80,4 @@ func GetScopedPackages(options *types.BeachballOptions, packageInfos types.Packa } return scoped -} \ No newline at end of file +} diff --git a/go/internal/testutil/package_infos.go b/go/internal/testutil/package_infos.go new file mode 100644 index 000000000..a8c505418 --- /dev/null +++ b/go/internal/testutil/package_infos.go @@ -0,0 +1,39 @@ +package testutil + +import ( + "path/filepath" + "runtime" + + "github.com/microsoft/beachball/internal/types" +) + +// 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" +} + +// 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/rust/tests/common/mod.rs b/rust/tests/common/mod.rs index ec82d324d..4e0ef6fb1 100644 --- a/rust/tests/common/mod.rs +++ b/rust/tests/common/mod.rs @@ -7,10 +7,12 @@ 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}; #[allow(dead_code)] pub const DEFAULT_BRANCH: &str = "master"; @@ -35,6 +37,55 @@ pub fn run_git(args: &[&str], cwd: &str) -> String { 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 { diff --git a/rust/tests/disallowed_change_types_test.rs b/rust/tests/disallowed_change_types_test.rs index 0bebd7675..8bec4a5d1 100644 --- a/rust/tests/disallowed_change_types_test.rs +++ b/rust/tests/disallowed_change_types_test.rs @@ -1,26 +1,23 @@ +mod common; + use beachball::changefile::change_types::get_disallowed_change_types; use beachball::types::change_info::ChangeType; use beachball::types::package_info::{ - PackageGroupInfo, PackageGroups, PackageInfo, PackageInfos, PackageOptions, + PackageGroupInfo, PackageGroups, PackageInfos, PackageOptions, }; +use common::{fake_root, make_package_infos_simple}; -fn make_info(name: &str) -> PackageInfo { - PackageInfo { - name: name.to_string(), - package_json_path: format!("/fake/{name}/package.json"), - version: "1.0.0".to_string(), - ..Default::default() - } +fn make_infos(name: &str) -> PackageInfos { + make_package_infos_simple(&[name], &fake_root()) } -fn make_info_with_disallowed(name: &str, disallowed: Vec) -> PackageInfo { - PackageInfo { - package_options: Some(PackageOptions { - disallowed_change_types: Some(disallowed), - ..Default::default() - }), - ..make_info(name) - } +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 } #[test] @@ -33,8 +30,7 @@ fn returns_none_for_unknown_package() { #[test] fn falls_back_to_repo_option() { - let mut infos = PackageInfos::new(); - infos.insert("foo".to_string(), make_info("foo")); + let infos = make_infos("foo"); let groups = PackageGroups::new(); let repo_disallowed = Some(vec![ChangeType::Major]); @@ -44,11 +40,7 @@ fn falls_back_to_repo_option() { #[test] fn returns_package_level_disallowed() { - let mut infos = PackageInfos::new(); - infos.insert( - "foo".to_string(), - make_info_with_disallowed("foo", vec![ChangeType::Major, ChangeType::Minor]), - ); + 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); @@ -57,8 +49,7 @@ fn returns_package_level_disallowed() { #[test] fn returns_group_level_disallowed() { - let mut infos = PackageInfos::new(); - infos.insert("foo".to_string(), make_info("foo")); + let infos = make_infos("foo"); let mut groups = PackageGroups::new(); groups.insert( @@ -75,11 +66,7 @@ fn returns_group_level_disallowed() { #[test] fn returns_package_level_if_not_in_group() { - let mut infos = PackageInfos::new(); - infos.insert( - "foo".to_string(), - make_info_with_disallowed("foo", vec![ChangeType::Minor]), - ); + let infos = make_infos_with_disallowed("foo", vec![ChangeType::Minor]); let mut groups = PackageGroups::new(); groups.insert( @@ -96,11 +83,7 @@ fn returns_package_level_if_not_in_group() { #[test] fn prefers_group_over_package() { - let mut infos = PackageInfos::new(); - infos.insert( - "foo".to_string(), - make_info_with_disallowed("foo", vec![ChangeType::Minor]), - ); + let infos = make_infos_with_disallowed("foo", vec![ChangeType::Minor]); let mut groups = PackageGroups::new(); groups.insert( diff --git a/rust/tests/package_groups_test.rs b/rust/tests/package_groups_test.rs index bcd4bd2ad..425fcfc07 100644 --- a/rust/tests/package_groups_test.rs +++ b/rust/tests/package_groups_test.rs @@ -1,42 +1,28 @@ +mod common; + use beachball::monorepo::package_groups::get_package_groups; use beachball::types::options::{VersionGroupInclude, VersionGroupOptions}; -use beachball::types::package_info::{PackageInfo, PackageInfos}; - -fn make_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: format!("{root}/{folder}/package.json"), - version: "1.0.0".to_string(), - ..Default::default() - }, - ); - } - infos -} - -const ROOT: &str = "/fake-root"; +use common::{fake_root, make_package_infos}; #[test] fn returns_empty_if_no_groups_defined() { - let infos = make_infos(&[("foo", "packages/foo")], ROOT); - let result = get_package_groups(&infos, ROOT, &None).unwrap(); + 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()); } #[test] fn returns_groups_based_on_specific_folders() { - let infos = make_infos( + 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, + &root, ); let groups = Some(vec![ @@ -54,7 +40,7 @@ fn returns_groups_based_on_specific_folders() { }, ]); - let result = get_package_groups(&infos, ROOT, &groups).unwrap(); + let result = get_package_groups(&infos, &root, &groups).unwrap(); assert_eq!(result.len(), 2); let mut grp1_pkgs = result["grp1"].package_names.clone(); @@ -68,13 +54,14 @@ fn returns_groups_based_on_specific_folders() { #[test] fn handles_single_level_globs() { - let infos = make_infos( + 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, + &root, ); let groups = Some(vec![VersionGroupOptions { @@ -84,7 +71,7 @@ fn handles_single_level_globs() { disallowed_change_types: None, }]); - let result = get_package_groups(&infos, ROOT, &groups).unwrap(); + 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"]); @@ -92,13 +79,14 @@ fn handles_single_level_globs() { #[test] fn handles_multi_level_globs() { - let infos = make_infos( + 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, + &root, ); let groups = Some(vec![VersionGroupOptions { @@ -108,7 +96,7 @@ fn handles_multi_level_globs() { disallowed_change_types: None, }]); - let result = get_package_groups(&infos, ROOT, &groups).unwrap(); + 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"]); @@ -116,13 +104,14 @@ fn handles_multi_level_globs() { #[test] fn handles_multiple_include_patterns() { - let infos = make_infos( + 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, + &root, ); let groups = Some(vec![VersionGroupOptions { @@ -135,7 +124,7 @@ fn handles_multiple_include_patterns() { disallowed_change_types: None, }]); - let result = get_package_groups(&infos, ROOT, &groups).unwrap(); + 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"]); @@ -143,13 +132,14 @@ fn handles_multiple_include_patterns() { #[test] fn handles_specific_exclude_patterns() { - let infos = make_infos( + let root = fake_root(); + let infos = make_package_infos( &[ ("pkg-a", "packages/pkg-a"), ("internal", "packages/internal"), ("pkg-b", "packages/pkg-b"), ], - ROOT, + &root, ); let groups = Some(vec![VersionGroupOptions { @@ -159,7 +149,7 @@ fn handles_specific_exclude_patterns() { disallowed_change_types: None, }]); - let result = get_package_groups(&infos, ROOT, &groups).unwrap(); + 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"]); @@ -167,13 +157,14 @@ fn handles_specific_exclude_patterns() { #[test] fn handles_glob_exclude_patterns() { - let infos = make_infos( + 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, + &root, ); let groups = Some(vec![VersionGroupOptions { @@ -183,15 +174,16 @@ fn handles_glob_exclude_patterns() { disallowed_change_types: None, }]); - let result = get_package_groups(&infos, ROOT, &groups).unwrap(); + let result = get_package_groups(&infos, &root, &groups).unwrap(); assert_eq!(result["non-core"].package_names, vec!["ui-a"]); } #[test] fn errors_if_package_in_multiple_groups() { - let infos = make_infos( + let root = fake_root(); + let infos = make_package_infos( &[("pkg-a", "packages/pkg-a"), ("pkg-b", "packages/pkg-b")], - ROOT, + &root, ); let groups = Some(vec![ @@ -209,7 +201,7 @@ fn errors_if_package_in_multiple_groups() { }, ]); - let result = get_package_groups(&infos, ROOT, &groups); + 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")); @@ -217,7 +209,8 @@ fn errors_if_package_in_multiple_groups() { #[test] fn omits_empty_groups() { - let infos = make_infos(&[("pkg-a", "packages/pkg-a")], ROOT); + let root = fake_root(); + let infos = make_package_infos(&[("pkg-a", "packages/pkg-a")], &root); let groups = Some(vec![VersionGroupOptions { name: "empty".to_string(), @@ -226,6 +219,6 @@ fn omits_empty_groups() { disallowed_change_types: None, }]); - let result = get_package_groups(&infos, ROOT, &groups).unwrap(); + let result = get_package_groups(&infos, &root, &groups).unwrap(); assert!(result["empty"].package_names.is_empty()); } From 2ba1334b9ed26b5ac8de7f9083a704fab606b81d Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Fri, 6 Mar 2026 16:32:09 -0800 Subject: [PATCH 11/38] config --- beachball.config.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/beachball.config.js b/beachball.config.js index 46b742484..c81988d4b 100644 --- a/beachball.config.js +++ b/beachball.config.js @@ -17,6 +17,8 @@ const config = { 'src/__*/**', // This one is especially important (otherwise dependabot would be blocked by change file requirements) 'yarn.lock', + 'rust/**/*', + 'go/**/*', ], }; From ce96e9eee08ca0afa68c5cc1deeea4868fb1c385 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Fri, 6 Mar 2026 16:44:41 -0800 Subject: [PATCH 12/38] use go test assert library --- go/go.mod | 4 + go/go.sum | 9 + go/internal/changefile/change_types_test.go | 25 +-- .../changefile/changed_packages_test.go | 154 ++++------------ .../changefile/write_change_files_test.go | 72 +++----- go/internal/commands/change_test.go | 174 +++++------------- go/internal/monorepo/package_groups_test.go | 83 ++------- go/internal/monorepo/path_included_test.go | 32 +--- .../are_change_files_deleted_test.go | 13 +- go/internal/validation/validate_test.go | 22 +-- 10 files changed, 165 insertions(+), 423 deletions(-) diff --git a/go/go.mod b/go/go.mod index c4bbb9130..b30a4b4ce 100644 --- a/go/go.mod +++ b/go/go.mod @@ -6,9 +6,13 @@ 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 ) 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 + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go/go.sum b/go/go.sum index df1c604a3..cbffcf44c 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,14 +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/change_types_test.go b/go/internal/changefile/change_types_test.go index 5842dc7f7..88c648ac6 100644 --- a/go/internal/changefile/change_types_test.go +++ b/go/internal/changefile/change_types_test.go @@ -6,6 +6,7 @@ import ( "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() @@ -16,9 +17,7 @@ func TestGetDisallowedChangeTypes_ReturnsNilForUnknownPackage(t *testing.T) { opts := &types.BeachballOptions{} result := changefile.GetDisallowedChangeTypes("unknown-pkg", infos, groups, opts) - if result != nil { - t.Fatalf("expected nil, got: %v", result) - } + assert.Nil(t, result) } func TestGetDisallowedChangeTypes_ReturnsNilWhenNoSettings(t *testing.T) { @@ -27,9 +26,7 @@ func TestGetDisallowedChangeTypes_ReturnsNilWhenNoSettings(t *testing.T) { opts := &types.BeachballOptions{} result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) - if result != nil { - t.Fatalf("expected nil, got: %v", result) - } + assert.Nil(t, result) } func TestGetDisallowedChangeTypes_ReturnsPackageLevelDisallowedTypes(t *testing.T) { @@ -41,9 +38,7 @@ func TestGetDisallowedChangeTypes_ReturnsPackageLevelDisallowedTypes(t *testing. opts := &types.BeachballOptions{} result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) - if len(result) != 1 || result[0] != "major" { - t.Fatalf("expected [major], got: %v", result) - } + assert.Equal(t, []string{"major"}, result) } func TestGetDisallowedChangeTypes_ReturnsGroupLevelDisallowedTypes(t *testing.T) { @@ -58,9 +53,7 @@ func TestGetDisallowedChangeTypes_ReturnsGroupLevelDisallowedTypes(t *testing.T) opts := &types.BeachballOptions{} result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) - if len(result) != 2 || result[0] != "major" || result[1] != "minor" { - t.Fatalf("expected [major minor], got: %v", result) - } + assert.Equal(t, []string{"major", "minor"}, result) } func TestGetDisallowedChangeTypes_ReturnsNilIfNotInGroup(t *testing.T) { @@ -75,9 +68,7 @@ func TestGetDisallowedChangeTypes_ReturnsNilIfNotInGroup(t *testing.T) { opts := &types.BeachballOptions{} result := changefile.GetDisallowedChangeTypes("bar", infos, groups, opts) - if result != nil { - t.Fatalf("expected nil, got: %v", result) - } + assert.Nil(t, result) } func TestGetDisallowedChangeTypes_PrefersPackageOverGroup(t *testing.T) { @@ -96,7 +87,5 @@ func TestGetDisallowedChangeTypes_PrefersPackageOverGroup(t *testing.T) { // The implementation checks package-level first, so it should return "major" result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) - if len(result) != 1 || result[0] != "major" { - t.Fatalf("expected [major], got: %v", result) - } + assert.Equal(t, []string{"major"}, result) } diff --git a/go/internal/changefile/changed_packages_test.go b/go/internal/changefile/changed_packages_test.go index 799d1fe68..cca41b111 100644 --- a/go/internal/changefile/changed_packages_test.go +++ b/go/internal/changefile/changed_packages_test.go @@ -9,6 +9,8 @@ import ( "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" ) const defaultBranch = "master" @@ -31,9 +33,7 @@ func getOptionsAndPackages(t *testing.T, repo *testutil.Repository, overrides *t parsed := options.GetParsedOptionsForTest(repo.RootPath(), cli, repoOpts) packageInfos, err := monorepo.GetPackageInfos(&parsed.Options) - if err != nil { - t.Fatalf("failed to get package infos: %v", err) - } + require.NoError(t, err, "failed to get package infos") scopedPackages := monorepo.GetScopedPackages(&parsed.Options, packageInfos) return parsed.Options, packageInfos, scopedPackages } @@ -50,12 +50,8 @@ func TestReturnsEmptyListWhenNoChanges(t *testing.T) { opts, infos, scoped := getOptionsAndPackages(t, repo, nil, nil) result, err := changefile.GetChangedPackages(&opts, infos, scoped) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result) != 0 { - t.Fatalf("expected empty list, got: %v", result) - } + require.NoError(t, err) + assert.Empty(t, result) } func TestReturnsPackageNameWhenChangesInBranch(t *testing.T) { @@ -66,12 +62,8 @@ func TestReturnsPackageNameWhenChangesInBranch(t *testing.T) { opts, infos, scoped := getOptionsAndPackages(t, repo, nil, nil) result, err := changefile.GetChangedPackages(&opts, infos, scoped) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result) != 1 || result[0] != "foo" { - t.Fatalf("expected [foo], got: %v", result) - } + require.NoError(t, err) + assert.Equal(t, []string{"foo"}, result) } func TestReturnsEmptyListForChangelogChanges(t *testing.T) { @@ -82,12 +74,8 @@ func TestReturnsEmptyListForChangelogChanges(t *testing.T) { opts, infos, scoped := getOptionsAndPackages(t, repo, nil, nil) result, err := changefile.GetChangedPackages(&opts, infos, scoped) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result) != 0 { - t.Fatalf("expected empty list, got: %v", result) - } + require.NoError(t, err) + assert.Empty(t, result) } func TestReturnsGivenPackageNamesAsIs(t *testing.T) { @@ -97,28 +85,14 @@ func TestReturnsGivenPackageNamesAsIs(t *testing.T) { cli := types.CliOptions{Package: []string{"foo"}} opts, infos, scoped := getOptionsAndPackages(t, repo, nil, &cli) result, err := changefile.GetChangedPackages(&opts, infos, scoped) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result) != 1 || result[0] != "foo" { - t.Fatalf("expected [foo], got: %v", result) - } + 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) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - expected := []string{"foo", "bar", "nope"} - if len(result2) != len(expected) { - t.Fatalf("expected %v, got: %v", expected, result2) - } - for i, v := range expected { - if result2[i] != v { - t.Fatalf("expected %v, got: %v", expected, result2) - } - } + require.NoError(t, err) + assert.Equal(t, []string{"foo", "bar", "nope"}, result2) } func TestReturnsAllPackagesWithAllTrue(t *testing.T) { @@ -129,19 +103,9 @@ func TestReturnsAllPackagesWithAllTrue(t *testing.T) { overrides.All = true opts, infos, scoped := getOptionsAndPackages(t, repo, &overrides, nil) result, err := changefile.GetChangedPackages(&opts, infos, scoped) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } + require.NoError(t, err) sort.Strings(result) - expected := []string{"a", "b", "bar", "baz", "foo"} - if len(result) != len(expected) { - t.Fatalf("expected %v, got: %v", expected, result) - } - for i, v := range expected { - if result[i] != v { - t.Fatalf("expected %v, got: %v", expected, result) - } - } + assert.Equal(t, []string{"a", "b", "bar", "baz", "foo"}, result) } // ===== Single package tests ===== @@ -152,21 +116,13 @@ func TestDetectsChangedFilesInSinglePackageRepo(t *testing.T) { opts, infos, scoped := getOptionsAndPackages(t, repo, nil, nil) result, err := changefile.GetChangedPackages(&opts, infos, scoped) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result) != 0 { - t.Fatalf("expected empty, got: %v", result) - } + require.NoError(t, err) + assert.Empty(t, result) repo.StageChange("foo.js") result2, err := changefile.GetChangedPackages(&opts, infos, scoped) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result2) != 1 || result2[0] != "foo" { - t.Fatalf("expected [foo], got: %v", result2) - } + require.NoError(t, err) + assert.Equal(t, []string{"foo"}, result2) } func TestRespectsIgnorePatterns(t *testing.T) { @@ -185,12 +141,8 @@ func TestRespectsIgnorePatterns(t *testing.T) { repo.Git([]string{"add", "-A"}) result, err := changefile.GetChangedPackages(&opts, infos, scoped) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result) != 0 { - t.Fatalf("expected empty, got: %v", result) - } + require.NoError(t, err) + assert.Empty(t, result) } // ===== Monorepo tests ===== @@ -201,21 +153,13 @@ func TestDetectsChangedFilesInMonorepo(t *testing.T) { opts, infos, scoped := getOptionsAndPackages(t, repo, nil, nil) result, err := changefile.GetChangedPackages(&opts, infos, scoped) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result) != 0 { - t.Fatalf("expected empty, got: %v", result) - } + require.NoError(t, err) + assert.Empty(t, result) repo.StageChange("packages/foo/test.js") result2, err := changefile.GetChangedPackages(&opts, infos, scoped) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result2) != 1 || result2[0] != "foo" { - t.Fatalf("expected [foo], got: %v", result2) - } + require.NoError(t, err) + assert.Equal(t, []string{"foo"}, result2) } func TestExcludesPackagesWithExistingChangeFiles(t *testing.T) { @@ -230,22 +174,14 @@ func TestExcludesPackagesWithExistingChangeFiles(t *testing.T) { testutil.GenerateChangeFiles(t, []string{"foo"}, &opts, repo) result, err := changefile.GetChangedPackages(&opts, infos, scoped) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result) != 0 { - t.Fatalf("expected empty but got: %v", result) - } + require.NoError(t, err) + assert.Empty(t, result) // Change bar => bar is the only changed package returned repo.StageChange("packages/bar/test.js") result2, err := changefile.GetChangedPackages(&opts, infos, scoped) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result2) != 1 || result2[0] != "bar" { - t.Fatalf("expected [bar], got: %v", result2) - } + require.NoError(t, err) + assert.Equal(t, []string{"bar"}, result2) } func TestIgnoresPackageChangesAsAppropriate(t *testing.T) { @@ -288,12 +224,8 @@ func TestIgnoresPackageChangesAsAppropriate(t *testing.T) { opts, infos, scoped := getOptionsAndPackages(t, repo, &overrides, nil) result, err := changefile.GetChangedPackages(&opts, infos, scoped) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result) != 1 || result[0] != "publish-me" { - t.Fatalf("expected [publish-me], got: %v", result) - } + require.NoError(t, err) + assert.Equal(t, []string{"publish-me"}, result) } func TestDetectsChangedFilesInMultiRootMonorepo(t *testing.T) { @@ -311,17 +243,11 @@ func TestDetectsChangedFilesInMultiRootMonorepo(t *testing.T) { parsedA := options.GetParsedOptionsForTest(pathA, types.CliOptions{}, optsA) infosA, err := monorepo.GetPackageInfos(&parsedA.Options) - if err != nil { - t.Fatalf("failed to get package infos: %v", err) - } + require.NoError(t, err, "failed to get package infos") scopedA := monorepo.GetScopedPackages(&parsedA.Options, infosA) resultA, err := changefile.GetChangedPackages(&parsedA.Options, infosA, scopedA) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(resultA) != 1 || resultA[0] != "@project-a/foo" { - t.Fatalf("expected [@project-a/foo], got: %v", resultA) - } + require.NoError(t, err) + assert.Equal(t, []string{"@project-a/foo"}, resultA) // Test from project-b root pathB := repo.PathTo("project-b") @@ -332,15 +258,9 @@ func TestDetectsChangedFilesInMultiRootMonorepo(t *testing.T) { parsedB := options.GetParsedOptionsForTest(pathB, types.CliOptions{}, optsB) infosB, err := monorepo.GetPackageInfos(&parsedB.Options) - if err != nil { - t.Fatalf("failed to get package infos: %v", err) - } + require.NoError(t, err, "failed to get package infos") scopedB := monorepo.GetScopedPackages(&parsedB.Options, infosB) resultB, err := changefile.GetChangedPackages(&parsedB.Options, infosB, scopedB) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(resultB) != 0 { - t.Fatalf("expected empty, got: %v", resultB) - } + require.NoError(t, err) + assert.Empty(t, resultB) } diff --git a/go/internal/changefile/write_change_files_test.go b/go/internal/changefile/write_change_files_test.go index fc06bc523..336f6b3b2 100644 --- a/go/internal/changefile/write_change_files_test.go +++ b/go/internal/changefile/write_change_files_test.go @@ -9,6 +9,8 @@ import ( "github.com/microsoft/beachball/internal/changefile" "github.com/microsoft/beachball/internal/testutil" "github.com/microsoft/beachball/internal/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestWriteChangeFiles_WritesIndividualChangeFiles(t *testing.T) { @@ -39,57 +41,38 @@ func TestWriteChangeFiles_WritesIndividualChangeFiles(t *testing.T) { } err := changefile.WriteChangeFiles(&opts, changes) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } + require.NoError(t, err) files := testutil.GetChangeFiles(&opts) - if len(files) != 2 { - t.Fatalf("expected 2 change files, got %d", len(files)) - } + require.Len(t, files, 2) // Verify file contents foundFoo := false foundBar := false for _, f := range files { data, err := os.ReadFile(f) - if err != nil { - t.Fatalf("failed to read %s: %v", f, err) - } + require.NoError(t, err, "failed to read %s", f) var change types.ChangeFileInfo - if err := json.Unmarshal(data, &change); err != nil { - t.Fatalf("failed to parse %s: %v", f, err) - } + require.NoError(t, json.Unmarshal(data, &change), "failed to parse %s", f) switch change.PackageName { case "foo": foundFoo = true - if change.Type != types.ChangeTypePatch { - t.Fatalf("expected patch for foo, got %s", change.Type) - } - if change.Comment != "fix foo" { - t.Fatalf("expected 'fix foo', got %q", change.Comment) - } + assert.Equal(t, types.ChangeTypePatch, change.Type) + assert.Equal(t, "fix foo", change.Comment) case "bar": foundBar = true - if change.Type != types.ChangeTypeMinor { - t.Fatalf("expected minor for bar, got %s", change.Type) - } - if change.Comment != "add bar feature" { - t.Fatalf("expected 'add bar feature', got %q", change.Comment) - } + assert.Equal(t, types.ChangeTypeMinor, change.Type) + assert.Equal(t, "add bar feature", change.Comment) default: t.Fatalf("unexpected package: %s", change.PackageName) } } - if !foundFoo || !foundBar { - t.Fatalf("expected both foo and bar change files, foundFoo=%v foundBar=%v", foundFoo, foundBar) - } + 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() - if status != "" { - t.Fatalf("expected clean working tree after commit, got: %s", status) - } + assert.Empty(t, status, "expected clean working tree after commit") } func TestWriteChangeFiles_RespectsChangeDirOption(t *testing.T) { @@ -114,31 +97,24 @@ func TestWriteChangeFiles_RespectsChangeDirOption(t *testing.T) { } err := changefile.WriteChangeFiles(&opts, changes) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } + require.NoError(t, err) // Verify the custom directory was used customPath := filepath.Join(repo.RootPath(), "my-changes") entries, err := os.ReadDir(customPath) - if err != nil { - t.Fatalf("failed to read custom change dir: %v", err) - } + require.NoError(t, err, "failed to read custom change dir") jsonCount := 0 for _, e := range entries { if filepath.Ext(e.Name()) == ".json" { jsonCount++ } } - if jsonCount != 1 { - t.Fatalf("expected 1 json file in my-changes, got %d", jsonCount) - } + assert.Equal(t, 1, jsonCount) // Default change dir should not exist defaultPath := filepath.Join(repo.RootPath(), "change") - if _, err := os.Stat(defaultPath); err == nil { - t.Fatal("expected default change dir to not exist") - } + _, err = os.Stat(defaultPath) + assert.True(t, os.IsNotExist(err), "expected default change dir to not exist") } func TestWriteChangeFiles_RespectsCommitFalse(t *testing.T) { @@ -166,19 +142,13 @@ func TestWriteChangeFiles_RespectsCommitFalse(t *testing.T) { } err := changefile.WriteChangeFiles(&opts, changes) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } + require.NoError(t, err) // Verify files exist files := testutil.GetChangeFiles(&opts) - if len(files) != 1 { - t.Fatalf("expected 1 change file, got %d", len(files)) - } + assert.Len(t, files, 1) // Verify HEAD hash is unchanged (no commit was made) headAfter := repo.Git([]string{"rev-parse", "HEAD"}) - if headBefore != headAfter { - t.Fatalf("expected HEAD to be unchanged, before=%s after=%s", headBefore, headAfter) - } + assert.Equal(t, headBefore, headAfter, "expected HEAD to be unchanged") } diff --git a/go/internal/commands/change_test.go b/go/internal/commands/change_test.go index b5517b68c..a6c62a8b4 100644 --- a/go/internal/commands/change_test.go +++ b/go/internal/commands/change_test.go @@ -10,6 +10,8 @@ import ( "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" ) const defaultBranch = "master" @@ -32,14 +34,10 @@ func TestDoesNotCreateChangeFilesWhenNoChanges(t *testing.T) { parsed := options.GetParsedOptionsForTest(repo.RootPath(), cli, repoOpts) err := commands.Change(parsed) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } + require.NoError(t, err) files := testutil.GetChangeFiles(&parsed.Options) - if len(files) != 0 { - t.Fatalf("expected no change files, got %d", len(files)) - } + assert.Empty(t, files) } func TestCreatesChangeFileWithTypeAndMessage(t *testing.T) { @@ -63,37 +61,21 @@ func TestCreatesChangeFileWithTypeAndMessage(t *testing.T) { parsed := options.GetParsedOptionsForTest(repo.RootPath(), cli, repoOpts) err := commands.Change(parsed) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } + require.NoError(t, err) files := testutil.GetChangeFiles(&parsed.Options) - if len(files) != 1 { - t.Fatalf("expected 1 change file, got %d", len(files)) - } + require.Len(t, files, 1) data, err := os.ReadFile(files[0]) - if err != nil { - t.Fatalf("failed to read change file: %v", err) - } + require.NoError(t, err) var change types.ChangeFileInfo - if err := json.Unmarshal(data, &change); err != nil { - t.Fatalf("failed to parse change file: %v", err) - } + require.NoError(t, json.Unmarshal(data, &change)) - if change.Type != types.ChangeTypePatch { - t.Fatalf("expected patch, got %s", change.Type) - } - if change.Comment != "test description" { - t.Fatalf("expected 'test description', got %q", change.Comment) - } - if change.PackageName != "foo" { - t.Fatalf("expected 'foo', got %q", change.PackageName) - } - if change.DependentChangeType != types.ChangeTypePatch { - t.Fatalf("expected patch dependent type, got %s", change.DependentChangeType) - } + 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) } func TestCreatesAndStagesChangeFile(t *testing.T) { @@ -116,30 +98,21 @@ func TestCreatesAndStagesChangeFile(t *testing.T) { } parsed := options.GetParsedOptionsForTest(repo.RootPath(), cli, repoOpts) - if err := commands.Change(parsed); err != nil { - t.Fatalf("unexpected error: %v", err) - } + err := commands.Change(parsed) + require.NoError(t, err) // Verify file is staged (git status shows "A ") status := repo.Status() - if !strings.Contains(status, "A ") { - t.Fatalf("expected staged file (A prefix), got status: %q", status) - } + assert.Contains(t, status, "A ") files := testutil.GetChangeFiles(&parsed.Options) - if len(files) != 1 { - t.Fatalf("expected 1 change file, got %d", len(files)) - } + require.Len(t, files, 1) data, _ := os.ReadFile(files[0]) var change types.ChangeFileInfo json.Unmarshal(data, &change) - if change.Comment != "stage me please" { - t.Fatalf("expected 'stage me please', got %q", change.Comment) - } - if change.PackageName != "foo" { - t.Fatalf("expected 'foo', got %q", change.PackageName) - } + assert.Equal(t, "stage me please", change.Comment) + assert.Equal(t, "foo", change.PackageName) } func TestCreatesAndCommitsChangeFile(t *testing.T) { @@ -159,27 +132,20 @@ func TestCreatesAndCommitsChangeFile(t *testing.T) { } parsed := options.GetParsedOptionsForTest(repo.RootPath(), cli, repoOpts) - if err := commands.Change(parsed); err != nil { - t.Fatalf("unexpected error: %v", err) - } + err := commands.Change(parsed) + require.NoError(t, err) // Verify clean git status (committed) status := repo.Status() - if status != "" { - t.Fatalf("expected clean status after commit, got: %q", status) - } + assert.Empty(t, status) files := testutil.GetChangeFiles(&parsed.Options) - if len(files) != 1 { - t.Fatalf("expected 1 change file, got %d", len(files)) - } + require.Len(t, files, 1) data, _ := os.ReadFile(files[0]) var change types.ChangeFileInfo json.Unmarshal(data, &change) - if change.Comment != "commit me please" { - t.Fatalf("expected 'commit me please', got %q", change.Comment) - } + assert.Equal(t, "commit me please", change.Comment) } func TestCreatesAndCommitsChangeFileWithChangeDir(t *testing.T) { @@ -200,31 +166,22 @@ func TestCreatesAndCommitsChangeFileWithChangeDir(t *testing.T) { } parsed := options.GetParsedOptionsForTest(repo.RootPath(), cli, repoOpts) - if err := commands.Change(parsed); err != nil { - t.Fatalf("unexpected error: %v", err) - } + err := commands.Change(parsed) + require.NoError(t, err) status := repo.Status() - if status != "" { - t.Fatalf("expected clean status after commit, got: %q", status) - } + assert.Empty(t, status) files := testutil.GetChangeFiles(&parsed.Options) - if len(files) != 1 { - t.Fatalf("expected 1 change file, got %d", len(files)) - } + require.Len(t, files, 1) // Verify file is in custom directory - if !strings.Contains(files[0], "changeDir") { - t.Fatalf("expected file in changeDir, got: %s", files[0]) - } + assert.True(t, strings.Contains(files[0], "changeDir"), "expected file in changeDir, got: %s", files[0]) data, _ := os.ReadFile(files[0]) var change types.ChangeFileInfo json.Unmarshal(data, &change) - if change.Comment != "commit me please" { - t.Fatalf("expected 'commit me please', got %q", change.Comment) - } + assert.Equal(t, "commit me please", change.Comment) } func TestCreatesChangeFileWhenNoChangesButPackageProvided(t *testing.T) { @@ -247,21 +204,16 @@ func TestCreatesChangeFileWhenNoChangesButPackageProvided(t *testing.T) { } parsed := options.GetParsedOptionsForTest(repo.RootPath(), cli, repoOpts) - if err := commands.Change(parsed); err != nil { - t.Fatalf("unexpected error: %v", err) - } + err := commands.Change(parsed) + require.NoError(t, err) files := testutil.GetChangeFiles(&parsed.Options) - if len(files) != 1 { - t.Fatalf("expected 1 change file, got %d", len(files)) - } + require.Len(t, files, 1) data, _ := os.ReadFile(files[0]) var change types.ChangeFileInfo json.Unmarshal(data, &change) - if change.PackageName != "foo" { - t.Fatalf("expected 'foo', got %q", change.PackageName) - } + assert.Equal(t, "foo", change.PackageName) } func TestCreatesAndCommitsChangeFilesForMultiplePackages(t *testing.T) { @@ -282,19 +234,14 @@ func TestCreatesAndCommitsChangeFilesForMultiplePackages(t *testing.T) { } parsed := options.GetParsedOptionsForTest(repo.RootPath(), cli, repoOpts) - if err := commands.Change(parsed); err != nil { - t.Fatalf("unexpected error: %v", err) - } + err := commands.Change(parsed) + require.NoError(t, err) status := repo.Status() - if status != "" { - t.Fatalf("expected clean status, got: %q", status) - } + assert.Empty(t, status) files := testutil.GetChangeFiles(&parsed.Options) - if len(files) != 2 { - t.Fatalf("expected 2 change files, got %d", len(files)) - } + require.Len(t, files, 2) packageNames := map[string]bool{} for _, f := range files { @@ -302,17 +249,12 @@ func TestCreatesAndCommitsChangeFilesForMultiplePackages(t *testing.T) { var change types.ChangeFileInfo json.Unmarshal(data, &change) packageNames[change.PackageName] = true - if change.Type != types.ChangeTypeMinor { - t.Fatalf("expected minor, got %s for %s", change.Type, change.PackageName) - } - if change.Comment != "multi-package change" { - t.Fatalf("expected 'multi-package change', got %q", change.Comment) - } + assert.Equal(t, types.ChangeTypeMinor, change.Type) + assert.Equal(t, "multi-package change", change.Comment) } - if !packageNames["foo"] || !packageNames["bar"] { - t.Fatalf("expected foo and bar, got %v", packageNames) - } + assert.True(t, packageNames["foo"], "expected foo") + assert.True(t, packageNames["bar"], "expected bar") } func TestCreatesAndCommitsGroupedChangeFile(t *testing.T) { @@ -334,42 +276,28 @@ func TestCreatesAndCommitsGroupedChangeFile(t *testing.T) { } parsed := options.GetParsedOptionsForTest(repo.RootPath(), cli, repoOpts) - if err := commands.Change(parsed); err != nil { - t.Fatalf("unexpected error: %v", err) - } + err := commands.Change(parsed) + require.NoError(t, err) status := repo.Status() - if status != "" { - t.Fatalf("expected clean status, got: %q", status) - } + assert.Empty(t, status) files := testutil.GetChangeFiles(&parsed.Options) - if len(files) != 1 { - t.Fatalf("expected 1 grouped change file, got %d", len(files)) - } + require.Len(t, files, 1) data, _ := os.ReadFile(files[0]) var grouped types.ChangeInfoMultiple - if err := json.Unmarshal(data, &grouped); err != nil { - t.Fatalf("failed to parse grouped change file: %v", err) - } + require.NoError(t, json.Unmarshal(data, &grouped)) - if len(grouped.Changes) != 2 { - t.Fatalf("expected 2 changes in grouped file, got %d", len(grouped.Changes)) - } + assert.Len(t, grouped.Changes, 2) packageNames := map[string]bool{} for _, change := range grouped.Changes { packageNames[change.PackageName] = true - if change.Type != types.ChangeTypeMinor { - t.Fatalf("expected minor, got %s for %s", change.Type, change.PackageName) - } - if change.Comment != "grouped change" { - t.Fatalf("expected 'grouped change', got %q", change.Comment) - } + assert.Equal(t, types.ChangeTypeMinor, change.Type) + assert.Equal(t, "grouped change", change.Comment) } - if !packageNames["foo"] || !packageNames["bar"] { - t.Fatalf("expected foo and bar, got %v", packageNames) - } + assert.True(t, packageNames["foo"], "expected foo") + assert.True(t, packageNames["bar"], "expected bar") } diff --git a/go/internal/monorepo/package_groups_test.go b/go/internal/monorepo/package_groups_test.go index b2778b6e2..f79fdeb95 100644 --- a/go/internal/monorepo/package_groups_test.go +++ b/go/internal/monorepo/package_groups_test.go @@ -7,6 +7,7 @@ import ( "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() @@ -16,9 +17,7 @@ func TestGetPackageGroups_ReturnsEmptyIfNoGroups(t *testing.T) { "packages/foo": "foo", }) result := monorepo.GetPackageGroups(infos, root, nil) - if len(result) != 0 { - t.Fatalf("expected empty map, got: %v", result) - } + assert.Empty(t, result) } func TestGetPackageGroups_ReturnsGroupsBasedOnSpecificFolders(t *testing.T) { @@ -34,20 +33,11 @@ func TestGetPackageGroups_ReturnsGroupsBasedOnSpecificFolders(t *testing.T) { }, } result := monorepo.GetPackageGroups(infos, root, groups) - if len(result) != 1 { - t.Fatalf("expected 1 group, got %d", len(result)) - } + assert.Len(t, result, 1) grp := result["grp1"] - if grp == nil { - t.Fatal("expected grp1 to exist") - } + assert.NotNil(t, grp) sort.Strings(grp.Packages) - if len(grp.Packages) != 2 { - t.Fatalf("expected 2 packages, got %d: %v", len(grp.Packages), grp.Packages) - } - if grp.Packages[0] != "bar" || grp.Packages[1] != "foo" { - t.Fatalf("expected [bar foo], got: %v", grp.Packages) - } + assert.Equal(t, []string{"bar", "foo"}, grp.Packages) } func TestGetPackageGroups_HandlesSingleLevelGlobs(t *testing.T) { @@ -64,16 +54,9 @@ func TestGetPackageGroups_HandlesSingleLevelGlobs(t *testing.T) { } result := monorepo.GetPackageGroups(infos, root, groups) grp := result["ui"] - if grp == nil { - t.Fatal("expected ui group to exist") - } + assert.NotNil(t, grp) sort.Strings(grp.Packages) - if len(grp.Packages) != 2 { - t.Fatalf("expected 2 packages, got %d: %v", len(grp.Packages), grp.Packages) - } - if grp.Packages[0] != "ui-button" || grp.Packages[1] != "ui-input" { - t.Fatalf("expected [ui-button ui-input], got: %v", grp.Packages) - } + assert.Equal(t, []string{"ui-button", "ui-input"}, grp.Packages) } func TestGetPackageGroups_HandlesMultiLevelGlobs(t *testing.T) { @@ -90,16 +73,9 @@ func TestGetPackageGroups_HandlesMultiLevelGlobs(t *testing.T) { } result := monorepo.GetPackageGroups(infos, root, groups) grp := result["ui"] - if grp == nil { - t.Fatal("expected ui group to exist") - } + assert.NotNil(t, grp) sort.Strings(grp.Packages) - if len(grp.Packages) != 2 { - t.Fatalf("expected 2 packages, got %d: %v", len(grp.Packages), grp.Packages) - } - if grp.Packages[0] != "ui-button" || grp.Packages[1] != "ui-input" { - t.Fatalf("expected [ui-button ui-input], got: %v", grp.Packages) - } + assert.Equal(t, []string{"ui-button", "ui-input"}, grp.Packages) } func TestGetPackageGroups_HandlesMultipleIncludePatterns(t *testing.T) { @@ -116,16 +92,9 @@ func TestGetPackageGroups_HandlesMultipleIncludePatterns(t *testing.T) { } result := monorepo.GetPackageGroups(infos, root, groups) grp := result["mixed"] - if grp == nil { - t.Fatal("expected mixed group to exist") - } + assert.NotNil(t, grp) sort.Strings(grp.Packages) - if len(grp.Packages) != 2 { - t.Fatalf("expected 2 packages, got %d: %v", len(grp.Packages), grp.Packages) - } - if grp.Packages[0] != "bar" || grp.Packages[1] != "foo" { - t.Fatalf("expected [bar foo], got: %v", grp.Packages) - } + assert.Equal(t, []string{"bar", "foo"}, grp.Packages) } func TestGetPackageGroups_HandlesExcludePatterns(t *testing.T) { @@ -143,16 +112,9 @@ func TestGetPackageGroups_HandlesExcludePatterns(t *testing.T) { } result := monorepo.GetPackageGroups(infos, root, groups) grp := result["public"] - if grp == nil { - t.Fatal("expected public group to exist") - } + assert.NotNil(t, grp) sort.Strings(grp.Packages) - if len(grp.Packages) != 2 { - t.Fatalf("expected 2 packages, got %d: %v", len(grp.Packages), grp.Packages) - } - if grp.Packages[0] != "bar" || grp.Packages[1] != "foo" { - t.Fatalf("expected [bar foo], got: %v", grp.Packages) - } + assert.Equal(t, []string{"bar", "foo"}, grp.Packages) } func TestGetPackageGroups_HandlesGlobExclude(t *testing.T) { @@ -170,16 +132,9 @@ func TestGetPackageGroups_HandlesGlobExclude(t *testing.T) { } result := monorepo.GetPackageGroups(infos, root, groups) grp := result["non-core"] - if grp == nil { - t.Fatal("expected non-core group to exist") - } + assert.NotNil(t, grp) sort.Strings(grp.Packages) - if len(grp.Packages) != 2 { - t.Fatalf("expected 2 packages, got %d: %v", len(grp.Packages), grp.Packages) - } - if grp.Packages[0] != "ui-button" || grp.Packages[1] != "ui-input" { - t.Fatalf("expected [ui-button ui-input], got: %v", grp.Packages) - } + assert.Equal(t, []string{"ui-button", "ui-input"}, grp.Packages) } func TestGetPackageGroups_OmitsEmptyGroups(t *testing.T) { @@ -194,10 +149,6 @@ func TestGetPackageGroups_OmitsEmptyGroups(t *testing.T) { } result := monorepo.GetPackageGroups(infos, root, groups) grp := result["empty"] - if grp == nil { - t.Fatal("expected empty group key to exist") - } - if len(grp.Packages) != 0 { - t.Fatalf("expected 0 packages, got %d: %v", len(grp.Packages), grp.Packages) - } + assert.NotNil(t, grp) + assert.Empty(t, grp.Packages) } diff --git a/go/internal/monorepo/path_included_test.go b/go/internal/monorepo/path_included_test.go index 924d53bc2..4f571cd74 100644 --- a/go/internal/monorepo/path_included_test.go +++ b/go/internal/monorepo/path_included_test.go @@ -4,60 +4,44 @@ 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"}, false) - if len(result) != 0 { - t.Fatalf("expected empty result, got: %v", result) - } + assert.Empty(t, result) } func TestFilterIgnoredFiles_MatchesPathPatterns(t *testing.T) { result := monorepo.FilterIgnoredFiles([]string{"tests/stuff.js"}, []string{"tests/**"}, false) - if len(result) != 0 { - t.Fatalf("expected empty result, got: %v", result) - } + assert.Empty(t, result) } func TestFilterIgnoredFiles_DoesNotMatchUnrelatedFiles(t *testing.T) { result := monorepo.FilterIgnoredFiles([]string{"src/index.js"}, []string{"*.test.js"}, false) - if len(result) != 1 || result[0] != "src/index.js" { - t.Fatalf("expected [src/index.js], got: %v", result) - } + assert.Equal(t, []string{"src/index.js"}, result) } func TestFilterIgnoredFiles_MatchesChangeDirPattern(t *testing.T) { result := monorepo.FilterIgnoredFiles([]string{"change/foo.json"}, []string{"change/*.json"}, false) - if len(result) != 0 { - t.Fatalf("expected empty result, got: %v", result) - } + assert.Empty(t, result) } func TestFilterIgnoredFiles_MatchesCHANGELOG(t *testing.T) { result := monorepo.FilterIgnoredFiles([]string{"packages/foo/CHANGELOG.md"}, []string{"CHANGELOG.md"}, false) - if len(result) != 0 { - t.Fatalf("expected empty result, got: %v", result) - } + 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, false) - if len(result) != 1 || result[0] != "src/index.js" { - t.Fatalf("expected [src/index.js], got: %v", result) - } + 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, false) - if len(result) != 2 { - t.Fatalf("expected 2 results, got: %v", result) - } - if result[0] != "src/index.js" || result[1] != "lib/utils.js" { - t.Fatalf("expected [src/index.js lib/utils.js], got: %v", result) - } + assert.Equal(t, []string{"src/index.js", "lib/utils.js"}, result) } diff --git a/go/internal/validation/are_change_files_deleted_test.go b/go/internal/validation/are_change_files_deleted_test.go index cd1897793..2fed3d458 100644 --- a/go/internal/validation/are_change_files_deleted_test.go +++ b/go/internal/validation/are_change_files_deleted_test.go @@ -8,6 +8,7 @@ import ( "github.com/microsoft/beachball/internal/testutil" "github.com/microsoft/beachball/internal/types" "github.com/microsoft/beachball/internal/validation" + "github.com/stretchr/testify/assert" ) func TestAreChangeFilesDeleted_FalseWhenNoChangeFilesDeleted(t *testing.T) { @@ -27,9 +28,7 @@ func TestAreChangeFilesDeleted_FalseWhenNoChangeFilesDeleted(t *testing.T) { repo.Checkout("-b", "test-no-delete", defaultBranch) result := validation.AreChangeFilesDeleted(&opts) - if result { - t.Fatal("expected false when no change files are deleted") - } + assert.False(t, result) } func TestAreChangeFilesDeleted_TrueWhenChangeFilesDeleted(t *testing.T) { @@ -56,9 +55,7 @@ func TestAreChangeFilesDeleted_TrueWhenChangeFilesDeleted(t *testing.T) { os.MkdirAll(changePath, 0o755) result := validation.AreChangeFilesDeleted(&opts) - if !result { - t.Fatal("expected true when change files are deleted") - } + assert.True(t, result) } func TestAreChangeFilesDeleted_WorksWithCustomChangeDir(t *testing.T) { @@ -84,7 +81,5 @@ func TestAreChangeFilesDeleted_WorksWithCustomChangeDir(t *testing.T) { os.MkdirAll(changePath, 0o755) result := validation.AreChangeFilesDeleted(&opts) - if !result { - t.Fatal("expected true when custom change files are deleted") - } + assert.True(t, result) } diff --git a/go/internal/validation/validate_test.go b/go/internal/validation/validate_test.go index a49fc5f97..aa9b42d61 100644 --- a/go/internal/validation/validate_test.go +++ b/go/internal/validation/validate_test.go @@ -7,6 +7,8 @@ import ( "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" ) const defaultBranch = "master" @@ -25,12 +27,8 @@ func TestSucceedsWithNoChanges(t *testing.T) { result, err := validation.Validate(parsed, validation.ValidateOptions{ CheckChangeNeeded: true, }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result.IsChangeNeeded { - t.Fatal("expected no change needed") - } + require.NoError(t, err) + assert.False(t, result.IsChangeNeeded) } func TestExitsWithErrorIfChangeFilesNeeded(t *testing.T) { @@ -47,9 +45,7 @@ func TestExitsWithErrorIfChangeFilesNeeded(t *testing.T) { _, err := validation.Validate(parsed, validation.ValidateOptions{ CheckChangeNeeded: true, }) - if err == nil { - t.Fatal("expected error but got nil") - } + assert.Error(t, err) } func TestReturnsWithoutErrorIfAllowMissingChangeFiles(t *testing.T) { @@ -67,10 +63,6 @@ func TestReturnsWithoutErrorIfAllowMissingChangeFiles(t *testing.T) { CheckChangeNeeded: true, AllowMissingChangeFiles: true, }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !result.IsChangeNeeded { - t.Fatal("expected change needed") - } + require.NoError(t, err) + assert.True(t, result.IsChangeNeeded) } From 26740124dcbcd69aa6c621c5e95671f90f89f3cf Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Fri, 6 Mar 2026 16:49:12 -0800 Subject: [PATCH 13/38] Change files --- change/beachball-8bc0af86-788d-4dee-84db-ff3f4c3f90f3.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/beachball-8bc0af86-788d-4dee-84db-ff3f4c3f90f3.json 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" +} From 9be875c4e1b4a1d39bdcd13c42f2cd97eb1eb284 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Fri, 6 Mar 2026 17:16:10 -0800 Subject: [PATCH 14/38] go test helpers --- .../changefile/changed_packages_test.go | 32 +++++----- go/internal/commands/change_test.go | 58 ++++++++----------- go/internal/testutil/package_infos.go | 10 ---- go/internal/testutil/paths.go | 15 +++++ go/internal/types/options.go | 1 + .../are_change_files_deleted_test.go | 31 +++++----- go/internal/validation/validate_test.go | 28 ++++----- 7 files changed, 86 insertions(+), 89 deletions(-) create mode 100644 go/internal/testutil/paths.go diff --git a/go/internal/changefile/changed_packages_test.go b/go/internal/changefile/changed_packages_test.go index cca41b111..f69118008 100644 --- a/go/internal/changefile/changed_packages_test.go +++ b/go/internal/changefile/changed_packages_test.go @@ -13,8 +13,14 @@ import ( "github.com/stretchr/testify/require" ) -const defaultBranch = "master" -const defaultRemoteBranch = "origin/master" +// 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() @@ -24,12 +30,10 @@ func getOptionsAndPackages(t *testing.T, repo *testutil.Repository, overrides *t cli = *extraCli } - repoOpts := types.DefaultOptions() + repoOpts := getDefaultOptions() if overrides != nil { repoOpts = *overrides } - repoOpts.Branch = defaultRemoteBranch - repoOpts.Fetch = false parsed := options.GetParsedOptionsForTest(repo.RootPath(), cli, repoOpts) packageInfos, err := monorepo.GetPackageInfos(&parsed.Options) @@ -39,7 +43,7 @@ func getOptionsAndPackages(t *testing.T, repo *testutil.Repository, overrides *t } func checkOutTestBranch(repo *testutil.Repository, name string) { - repo.Checkout("-b", name, defaultBranch) + repo.Checkout("-b", name, testutil.DefaultBranch) } // ===== Basic tests ===== @@ -99,7 +103,7 @@ func TestReturnsAllPackagesWithAllTrue(t *testing.T) { factory := testutil.NewRepositoryFactory(t, "monorepo") repo := factory.CloneRepository() - overrides := types.DefaultOptions() + overrides := getDefaultOptions() overrides.All = true opts, infos, scoped := getOptionsAndPackages(t, repo, &overrides, nil) result, err := changefile.GetChangedPackages(&opts, infos, scoped) @@ -129,7 +133,7 @@ func TestRespectsIgnorePatterns(t *testing.T) { factory := testutil.NewRepositoryFactory(t, "single") repo := factory.CloneRepository() - overrides := types.DefaultOptions() + overrides := getDefaultOptions() overrides.IgnorePatterns = []string{"*.test.js", "tests/**", "yarn.lock"} overrides.Verbose = true @@ -168,7 +172,7 @@ func TestExcludesPackagesWithExistingChangeFiles(t *testing.T) { repo.Checkout("-b", "test") repo.CommitChange("packages/foo/test.js") - overrides := types.DefaultOptions() + overrides := getDefaultOptions() overrides.Verbose = true opts, infos, scoped := getOptionsAndPackages(t, repo, &overrides, nil) testutil.GenerateChangeFiles(t, []string{"foo"}, &opts, repo) @@ -217,7 +221,7 @@ func TestIgnoresPackageChangesAsAppropriate(t *testing.T) { repo.StageChange("packages/ignore-pkg/CHANGELOG.md") repo.StageChange("packages/publish-me/test.js") - overrides := types.DefaultOptions() + overrides := getDefaultOptions() overrides.Scope = []string{"!packages/out-of-scope"} overrides.IgnorePatterns = []string{"**/jest.config.js"} overrides.Verbose = true @@ -236,10 +240,8 @@ func TestDetectsChangedFilesInMultiRootMonorepo(t *testing.T) { // Test from project-a root pathA := repo.PathTo("project-a") - optsA := types.DefaultOptions() + optsA := getDefaultOptions() optsA.Path = pathA - optsA.Branch = defaultRemoteBranch - optsA.Fetch = false parsedA := options.GetParsedOptionsForTest(pathA, types.CliOptions{}, optsA) infosA, err := monorepo.GetPackageInfos(&parsedA.Options) @@ -251,10 +253,8 @@ func TestDetectsChangedFilesInMultiRootMonorepo(t *testing.T) { // Test from project-b root pathB := repo.PathTo("project-b") - optsB := types.DefaultOptions() + optsB := getDefaultOptions() optsB.Path = pathB - optsB.Branch = defaultRemoteBranch - optsB.Fetch = false parsedB := options.GetParsedOptionsForTest(pathB, types.CliOptions{}, optsB) infosB, err := monorepo.GetPackageInfos(&parsedB.Options) diff --git a/go/internal/commands/change_test.go b/go/internal/commands/change_test.go index a6c62a8b4..7d2f06732 100644 --- a/go/internal/commands/change_test.go +++ b/go/internal/commands/change_test.go @@ -14,17 +14,21 @@ import ( "github.com/stretchr/testify/require" ) -const defaultBranch = "master" -const defaultRemoteBranch = "origin/master" +// 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 TestDoesNotCreateChangeFilesWhenNoChanges(t *testing.T) { factory := testutil.NewRepositoryFactory(t, "single") repo := factory.CloneRepository() - repo.Checkout("-b", "no-changes-test", defaultBranch) + repo.Checkout("-b", "no-changes-test", testutil.DefaultBranch) - repoOpts := types.DefaultOptions() - repoOpts.Branch = defaultRemoteBranch - repoOpts.Fetch = false + repoOpts := getDefaultOptions() cli := types.CliOptions{ Command: "change", @@ -43,12 +47,10 @@ func TestDoesNotCreateChangeFilesWhenNoChanges(t *testing.T) { func TestCreatesChangeFileWithTypeAndMessage(t *testing.T) { factory := testutil.NewRepositoryFactory(t, "single") repo := factory.CloneRepository() - repo.Checkout("-b", "creates-change-test", defaultBranch) + repo.Checkout("-b", "creates-change-test", testutil.DefaultBranch) repo.CommitChange("file.js") - repoOpts := types.DefaultOptions() - repoOpts.Branch = defaultRemoteBranch - repoOpts.Fetch = false + repoOpts := getDefaultOptions() repoOpts.Commit = false commitFalse := false @@ -81,12 +83,10 @@ func TestCreatesChangeFileWithTypeAndMessage(t *testing.T) { func TestCreatesAndStagesChangeFile(t *testing.T) { factory := testutil.NewRepositoryFactory(t, "single") repo := factory.CloneRepository() - repo.Checkout("-b", "stages-change-test", defaultBranch) + repo.Checkout("-b", "stages-change-test", testutil.DefaultBranch) repo.CommitChange("file.js") - repoOpts := types.DefaultOptions() - repoOpts.Branch = defaultRemoteBranch - repoOpts.Fetch = false + repoOpts := getDefaultOptions() repoOpts.Commit = false commitFalse := false @@ -118,12 +118,10 @@ func TestCreatesAndStagesChangeFile(t *testing.T) { func TestCreatesAndCommitsChangeFile(t *testing.T) { factory := testutil.NewRepositoryFactory(t, "single") repo := factory.CloneRepository() - repo.Checkout("-b", "commits-change-test", defaultBranch) + repo.Checkout("-b", "commits-change-test", testutil.DefaultBranch) repo.CommitChange("file.js") - repoOpts := types.DefaultOptions() - repoOpts.Branch = defaultRemoteBranch - repoOpts.Fetch = false + repoOpts := getDefaultOptions() cli := types.CliOptions{ Command: "change", @@ -151,12 +149,10 @@ func TestCreatesAndCommitsChangeFile(t *testing.T) { func TestCreatesAndCommitsChangeFileWithChangeDir(t *testing.T) { factory := testutil.NewRepositoryFactory(t, "single") repo := factory.CloneRepository() - repo.Checkout("-b", "changedir-test", defaultBranch) + repo.Checkout("-b", "changedir-test", testutil.DefaultBranch) repo.CommitChange("file.js") - repoOpts := types.DefaultOptions() - repoOpts.Branch = defaultRemoteBranch - repoOpts.Fetch = false + repoOpts := getDefaultOptions() repoOpts.ChangeDir = "changeDir" cli := types.CliOptions{ @@ -187,11 +183,9 @@ func TestCreatesAndCommitsChangeFileWithChangeDir(t *testing.T) { func TestCreatesChangeFileWhenNoChangesButPackageProvided(t *testing.T) { factory := testutil.NewRepositoryFactory(t, "single") repo := factory.CloneRepository() - repo.Checkout("-b", "package-flag-test", defaultBranch) + repo.Checkout("-b", "package-flag-test", testutil.DefaultBranch) - repoOpts := types.DefaultOptions() - repoOpts.Branch = defaultRemoteBranch - repoOpts.Fetch = false + repoOpts := getDefaultOptions() repoOpts.Commit = false commitFalse := false @@ -219,13 +213,11 @@ func TestCreatesChangeFileWhenNoChangesButPackageProvided(t *testing.T) { func TestCreatesAndCommitsChangeFilesForMultiplePackages(t *testing.T) { factory := testutil.NewRepositoryFactory(t, "monorepo") repo := factory.CloneRepository() - repo.Checkout("-b", "multi-pkg-test", defaultBranch) + repo.Checkout("-b", "multi-pkg-test", testutil.DefaultBranch) repo.CommitChange("packages/foo/file.js") repo.CommitChange("packages/bar/file.js") - repoOpts := types.DefaultOptions() - repoOpts.Branch = defaultRemoteBranch - repoOpts.Fetch = false + repoOpts := getDefaultOptions() cli := types.CliOptions{ Command: "change", @@ -260,13 +252,11 @@ func TestCreatesAndCommitsChangeFilesForMultiplePackages(t *testing.T) { func TestCreatesAndCommitsGroupedChangeFile(t *testing.T) { factory := testutil.NewRepositoryFactory(t, "monorepo") repo := factory.CloneRepository() - repo.Checkout("-b", "grouped-test", defaultBranch) + repo.Checkout("-b", "grouped-test", testutil.DefaultBranch) repo.CommitChange("packages/foo/file.js") repo.CommitChange("packages/bar/file.js") - repoOpts := types.DefaultOptions() - repoOpts.Branch = defaultRemoteBranch - repoOpts.Fetch = false + repoOpts := getDefaultOptions() repoOpts.GroupChanges = true cli := types.CliOptions{ diff --git a/go/internal/testutil/package_infos.go b/go/internal/testutil/package_infos.go index a8c505418..47061adb7 100644 --- a/go/internal/testutil/package_infos.go +++ b/go/internal/testutil/package_infos.go @@ -2,20 +2,10 @@ package testutil import ( "path/filepath" - "runtime" "github.com/microsoft/beachball/internal/types" ) -// 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" -} - // 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) 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/types/options.go b/go/internal/types/options.go index b0710ada4..4cb18a437 100644 --- a/go/internal/types/options.go +++ b/go/internal/types/options.go @@ -25,6 +25,7 @@ type BeachballOptions struct { } // DefaultOptions returns BeachballOptions with sensible defaults. +// TODO: better default path value, or require path passed? func DefaultOptions() BeachballOptions { return BeachballOptions{ Branch: "origin/master", diff --git a/go/internal/validation/are_change_files_deleted_test.go b/go/internal/validation/are_change_files_deleted_test.go index 2fed3d458..3d3c250e7 100644 --- a/go/internal/validation/are_change_files_deleted_test.go +++ b/go/internal/validation/are_change_files_deleted_test.go @@ -11,21 +11,28 @@ import ( "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 +} + func TestAreChangeFilesDeleted_FalseWhenNoChangeFilesDeleted(t *testing.T) { factory := testutil.NewRepositoryFactory(t, "monorepo") repo := factory.CloneRepository() // Create a change file on master and push it - opts := types.DefaultOptions() - opts.Path = repo.RootPath() - opts.Branch = defaultRemoteBranch - opts.Fetch = false + 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", defaultBranch) + repo.Checkout("-b", "test-no-delete", testutil.DefaultBranch) result := validation.AreChangeFilesDeleted(&opts) assert.False(t, result) @@ -36,16 +43,13 @@ func TestAreChangeFilesDeleted_TrueWhenChangeFilesDeleted(t *testing.T) { repo := factory.CloneRepository() // Create a change file on master and push it - opts := types.DefaultOptions() - opts.Path = repo.RootPath() - opts.Branch = defaultRemoteBranch - opts.Fetch = false + 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", defaultBranch) + repo.Checkout("-b", "test-delete", testutil.DefaultBranch) changePath := filepath.Join(repo.RootPath(), opts.ChangeDir) repo.Git([]string{"rm", "-r", changePath}) @@ -62,16 +66,13 @@ func TestAreChangeFilesDeleted_WorksWithCustomChangeDir(t *testing.T) { factory := testutil.NewRepositoryFactory(t, "monorepo") repo := factory.CloneRepository() - opts := types.DefaultOptions() - opts.Path = repo.RootPath() - opts.Branch = defaultRemoteBranch - opts.Fetch = false + opts := getDefaultOptionsWithPath(repo.RootPath()) opts.ChangeDir = "custom-changes" testutil.GenerateChangeFiles(t, []string{"foo"}, &opts, repo) repo.Push() - repo.Checkout("-b", "test-custom-delete", defaultBranch) + repo.Checkout("-b", "test-custom-delete", testutil.DefaultBranch) changePath := filepath.Join(repo.RootPath(), opts.ChangeDir) repo.Git([]string{"rm", "-r", changePath}) diff --git a/go/internal/validation/validate_test.go b/go/internal/validation/validate_test.go index aa9b42d61..d15ac8ea7 100644 --- a/go/internal/validation/validate_test.go +++ b/go/internal/validation/validate_test.go @@ -11,17 +11,21 @@ import ( "github.com/stretchr/testify/require" ) -const defaultBranch = "master" -const defaultRemoteBranch = "origin/master" +// 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 TestSucceedsWithNoChanges(t *testing.T) { factory := testutil.NewRepositoryFactory(t, "monorepo") repo := factory.CloneRepository() - repo.Checkout("-b", "test", defaultBranch) + repo.Checkout("-b", "test", testutil.DefaultBranch) - repoOpts := types.DefaultOptions() - repoOpts.Branch = defaultRemoteBranch - repoOpts.Fetch = false + repoOpts := getDefaultOptions() parsed := options.GetParsedOptionsForTest(repo.RootPath(), types.CliOptions{}, repoOpts) result, err := validation.Validate(parsed, validation.ValidateOptions{ @@ -34,12 +38,10 @@ func TestSucceedsWithNoChanges(t *testing.T) { func TestExitsWithErrorIfChangeFilesNeeded(t *testing.T) { factory := testutil.NewRepositoryFactory(t, "monorepo") repo := factory.CloneRepository() - repo.Checkout("-b", "test", defaultBranch) + repo.Checkout("-b", "test", testutil.DefaultBranch) repo.CommitChange("packages/foo/test.js") - repoOpts := types.DefaultOptions() - repoOpts.Branch = defaultRemoteBranch - repoOpts.Fetch = false + repoOpts := getDefaultOptions() parsed := options.GetParsedOptionsForTest(repo.RootPath(), types.CliOptions{}, repoOpts) _, err := validation.Validate(parsed, validation.ValidateOptions{ @@ -51,12 +53,10 @@ func TestExitsWithErrorIfChangeFilesNeeded(t *testing.T) { func TestReturnsWithoutErrorIfAllowMissingChangeFiles(t *testing.T) { factory := testutil.NewRepositoryFactory(t, "monorepo") repo := factory.CloneRepository() - repo.Checkout("-b", "test", defaultBranch) + repo.Checkout("-b", "test", testutil.DefaultBranch) repo.CommitChange("packages/foo/test.js") - repoOpts := types.DefaultOptions() - repoOpts.Branch = defaultRemoteBranch - repoOpts.Fetch = false + repoOpts := getDefaultOptions() parsed := options.GetParsedOptionsForTest(repo.RootPath(), types.CliOptions{}, repoOpts) result, err := validation.Validate(parsed, validation.ValidateOptions{ From 1399add55f8438921933322665127d8e86d220c1 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Fri, 6 Mar 2026 17:24:01 -0800 Subject: [PATCH 15/38] go logging --- go/cmd/beachball/main.go | 4 ++-- go/internal/changefile/changed_packages.go | 16 ++++++------- go/internal/changefile/write_change_files.go | 7 +++--- .../changefile/write_change_files_test.go | 12 +++++----- go/internal/commands/change.go | 3 ++- go/internal/commands/check.go | 5 ++-- go/internal/git/ensure_shared_history.go | 5 ++-- go/internal/logging/logging.go | 23 +++++++++++++++++++ go/internal/monorepo/filter_ignored.go | 4 ++-- go/internal/validation/validate.go | 13 +++++------ 10 files changed, 58 insertions(+), 34 deletions(-) diff --git a/go/cmd/beachball/main.go b/go/cmd/beachball/main.go index 4758d3e65..d2e00eccc 100644 --- a/go/cmd/beachball/main.go +++ b/go/cmd/beachball/main.go @@ -1,10 +1,10 @@ package main import ( - "fmt" "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" @@ -84,7 +84,7 @@ func main() { rootCmd.AddCommand(checkCmd, changeCmd) if err := rootCmd.Execute(); err != nil { - fmt.Fprintln(os.Stderr, err) + logging.Error.Println(err) os.Exit(1) } } diff --git a/go/internal/changefile/changed_packages.go b/go/internal/changefile/changed_packages.go index b58c2f4df..bbd194ba9 100644 --- a/go/internal/changefile/changed_packages.go +++ b/go/internal/changefile/changed_packages.go @@ -57,7 +57,7 @@ func getAllChangedPackages(options *types.BeachballOptions, packageInfos types.P if options.All { if verbose { - fmt.Fprintln(os.Stderr, "--all option was provided, so including all packages that are in scope (regardless of changes)") + logging.Info.Println("--all option was provided, so including all packages that are in scope (regardless of changes)") } var result []string for _, pkg := range packageInfos { @@ -69,7 +69,7 @@ func getAllChangedPackages(options *types.BeachballOptions, packageInfos types.P return result, nil } - fmt.Printf("Checking for changes against %q\n", options.Branch) + logging.Info.Printf("Checking for changes against %q", options.Branch) if err := git.EnsureSharedHistory(options); err != nil { return nil, err @@ -97,7 +97,7 @@ func getAllChangedPackages(options *types.BeachballOptions, packageInfos types.P if count == 1 { s = "" } - fmt.Printf("Found %d changed file%s in current branch (before filtering)\n", count, s) + logging.Info.Printf("Found %d changed file%s in current branch (before filtering)", count, s) } if len(changes) == 0 { @@ -113,7 +113,7 @@ func getAllChangedPackages(options *types.BeachballOptions, packageInfos types.P if len(nonIgnored) == 0 { if verbose { - fmt.Fprintln(os.Stderr, "All files were ignored") + logging.Info.Println("All files were ignored") } return nil, nil } @@ -138,13 +138,13 @@ func getAllChangedPackages(options *types.BeachballOptions, packageInfos types.P if !included { if verbose { - fmt.Fprintf(os.Stderr, " - ~~%s~~ (%s)\n", file, reason) + logging.Info.Printf(" - ~~%s~~ (%s)", file, reason) } } else { includedPackages[pkgInfo.Name] = true fileCount++ if verbose { - fmt.Fprintf(os.Stderr, " - %s\n", file) + logging.Info.Printf(" - %s", file) } } } @@ -159,7 +159,7 @@ func getAllChangedPackages(options *types.BeachballOptions, packageInfos types.P if pkgCount == 1 { ps = "" } - fmt.Printf("Found %d file%s in %d package%s that should be published\n", fileCount, fs, pkgCount, ps) + logging.Info.Printf("Found %d file%s in %d package%s that should be published", fileCount, fs, pkgCount, ps) } var result []string @@ -220,7 +220,7 @@ func GetChangedPackages(options *types.BeachballOptions, packageInfos types.Pack for name := range existingPackages { sorted = append(sorted, name) } - fmt.Printf("Your local repository already has change files for these packages:\n%s\n", + logging.Info.Printf("Your local repository already has change files for these packages:\n%s", logging.BulletedList(sorted)) } diff --git a/go/internal/changefile/write_change_files.go b/go/internal/changefile/write_change_files.go index 8f59acc7f..b2edfcd31 100644 --- a/go/internal/changefile/write_change_files.go +++ b/go/internal/changefile/write_change_files.go @@ -9,6 +9,7 @@ import ( "github.com/google/uuid" "github.com/microsoft/beachball/internal/git" + "github.com/microsoft/beachball/internal/logging" "github.com/microsoft/beachball/internal/types" ) @@ -40,7 +41,7 @@ func WriteChangeFiles(options *types.BeachballOptions, changes []types.ChangeFil } filePaths = append(filePaths, filePath) - fmt.Printf("Wrote change file: %s\n", filename) + logging.Info.Printf("Wrote change file: %s", filename) } else { for _, change := range changes { id := uuid.New().String() @@ -58,7 +59,7 @@ func WriteChangeFiles(options *types.BeachballOptions, changes []types.ChangeFil } filePaths = append(filePaths, filePath) - fmt.Printf("Wrote change file: %s\n", filename) + logging.Info.Printf("Wrote change file: %s", filename) } } @@ -72,7 +73,7 @@ func WriteChangeFiles(options *types.BeachballOptions, changes []types.ChangeFil if err := git.Commit(msg, options.Path); err != nil { return fmt.Errorf("failed to commit change files: %w", err) } - fmt.Println("Committed change files") + logging.Info.Println("Committed change files") } } diff --git a/go/internal/changefile/write_change_files_test.go b/go/internal/changefile/write_change_files_test.go index 336f6b3b2..46341b931 100644 --- a/go/internal/changefile/write_change_files_test.go +++ b/go/internal/changefile/write_change_files_test.go @@ -16,11 +16,11 @@ import ( func TestWriteChangeFiles_WritesIndividualChangeFiles(t *testing.T) { factory := testutil.NewRepositoryFactory(t, "monorepo") repo := factory.CloneRepository() - repo.Checkout("-b", "write-test", defaultBranch) + repo.Checkout("-b", "write-test", testutil.DefaultBranch) opts := types.DefaultOptions() opts.Path = repo.RootPath() - opts.Branch = defaultRemoteBranch + opts.Branch = testutil.DefaultRemoteBranch opts.Fetch = false changes := []types.ChangeFileInfo{ @@ -78,11 +78,11 @@ func TestWriteChangeFiles_WritesIndividualChangeFiles(t *testing.T) { func TestWriteChangeFiles_RespectsChangeDirOption(t *testing.T) { factory := testutil.NewRepositoryFactory(t, "monorepo") repo := factory.CloneRepository() - repo.Checkout("-b", "custom-dir-test", defaultBranch) + repo.Checkout("-b", "custom-dir-test", testutil.DefaultBranch) opts := types.DefaultOptions() opts.Path = repo.RootPath() - opts.Branch = defaultRemoteBranch + opts.Branch = testutil.DefaultRemoteBranch opts.Fetch = false opts.ChangeDir = "my-changes" @@ -120,14 +120,14 @@ func TestWriteChangeFiles_RespectsChangeDirOption(t *testing.T) { func TestWriteChangeFiles_RespectsCommitFalse(t *testing.T) { factory := testutil.NewRepositoryFactory(t, "monorepo") repo := factory.CloneRepository() - repo.Checkout("-b", "no-commit-test", defaultBranch) + 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 = defaultRemoteBranch + opts.Branch = testutil.DefaultRemoteBranch opts.Fetch = false opts.Commit = false diff --git a/go/internal/commands/change.go b/go/internal/commands/change.go index aa71c050c..0a65909f6 100644 --- a/go/internal/commands/change.go +++ b/go/internal/commands/change.go @@ -5,6 +5,7 @@ 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" "github.com/microsoft/beachball/internal/validation" ) @@ -20,7 +21,7 @@ func Change(parsed types.ParsedOptions) error { } if !result.IsChangeNeeded && len(parsed.Options.Package) == 0 { - fmt.Println("No changes detected; no change files are needed.") + logging.Info.Println("No changes detected; no change files are needed.") return nil } diff --git a/go/internal/commands/check.go b/go/internal/commands/check.go index 55f47233d..2ba257600 100644 --- a/go/internal/commands/check.go +++ b/go/internal/commands/check.go @@ -1,8 +1,7 @@ package commands import ( - "fmt" - + "github.com/microsoft/beachball/internal/logging" "github.com/microsoft/beachball/internal/types" "github.com/microsoft/beachball/internal/validation" ) @@ -16,6 +15,6 @@ func Check(parsed types.ParsedOptions) error { return err } - fmt.Println("No change files are needed!") + logging.Info.Println("No change files are needed!") return nil } diff --git a/go/internal/git/ensure_shared_history.go b/go/internal/git/ensure_shared_history.go index 2d98ef371..61b140961 100644 --- a/go/internal/git/ensure_shared_history.go +++ b/go/internal/git/ensure_shared_history.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/microsoft/beachball/internal/logging" "github.com/microsoft/beachball/internal/types" ) @@ -24,14 +25,14 @@ func EnsureSharedHistory(options *types.BeachballOptions) error { return fmt.Errorf("invalid branch format: %s", options.Branch) } - fmt.Printf("Fetching %s from %s...\n", parts[1], parts[0]) + 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) { - fmt.Println("Shallow clone detected, deepening...") + logging.Info.Println("Shallow clone detected, deepening...") if err := Deepen(100, cwd); err != nil { return fmt.Errorf("failed to deepen: %w", err) } diff --git a/go/internal/logging/logging.go b/go/internal/logging/logging.go index 2f9febe04..7e845af42 100644 --- a/go/internal/logging/logging.go +++ b/go/internal/logging/logging.go @@ -2,9 +2,32 @@ package logging import ( "fmt" + "io" + "log" + "os" "strings" ) +var ( + 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). +func SetOutput(w io.Writer) { + Info.SetOutput(w) + Warn.SetOutput(w) + Error.SetOutput(w) +} + +// Reset restores loggers to their default outputs. +func Reset() { + Info.SetOutput(os.Stdout) + Warn.SetOutput(os.Stderr) + Error.SetOutput(os.Stderr) +} + // BulletedList formats a list of strings as a bulleted list. func BulletedList(items []string) string { var sb strings.Builder diff --git a/go/internal/monorepo/filter_ignored.go b/go/internal/monorepo/filter_ignored.go index 7897faca6..acf7a0e04 100644 --- a/go/internal/monorepo/filter_ignored.go +++ b/go/internal/monorepo/filter_ignored.go @@ -1,10 +1,10 @@ package monorepo import ( - "fmt" "path/filepath" "github.com/bmatcuk/doublestar/v4" + "github.com/microsoft/beachball/internal/logging" ) // FilterIgnoredFiles filters out files that match ignore patterns. @@ -27,7 +27,7 @@ func FilterIgnoredFiles(files []string, patterns []string, verbose bool) []strin if ignored { if verbose { - fmt.Printf(" - ~~%s~~ (ignored by pattern %q)\n", file, matchedPattern) + logging.Info.Printf(" - ~~%s~~ (ignored by pattern %q)", file, matchedPattern) } } else { result = append(result, file) diff --git a/go/internal/validation/validate.go b/go/internal/validation/validate.go index ec5aeb8c7..47a229a44 100644 --- a/go/internal/validation/validate.go +++ b/go/internal/validation/validate.go @@ -2,7 +2,6 @@ package validation import ( "fmt" - "os" "sort" "strings" @@ -35,16 +34,16 @@ func Validate(parsed types.ParsedOptions, validateOpts ValidateOptions) (*Valida hasError := false logError := func(msg string) { - fmt.Fprintf(os.Stderr, "ERROR: %s\n", msg) + logging.Error.Println(msg) hasError = true } - fmt.Println("\nValidating options and change files...") + logging.Info.Println("\nValidating options and change files...") // Check for untracked changes untracked, _ := git.GetUntrackedChanges(options.Path) if len(untracked) > 0 { - fmt.Fprintf(os.Stderr, "WARN: There are untracked changes in your repository:\n%s\n", + logging.Warn.Printf("There are untracked changes in your repository:\n%s\n", logging.BulletedList(untracked)) } @@ -148,12 +147,12 @@ func Validate(parsed types.ParsedOptions, validateOpts ValidateOptions) (*Valida sorted := make([]string, len(changedPackages)) copy(sorted, changedPackages) sort.Strings(sorted) - fmt.Printf("%s:\n%s\n", msg, logging.BulletedList(sorted)) + logging.Info.Printf("%s:\n%s\n", msg, logging.BulletedList(sorted)) } if result.IsChangeNeeded && !validateOpts.AllowMissingChangeFiles { logError("Change files are needed!") - fmt.Println(options.ChangeHint) + logging.Info.Println(options.ChangeHint) return nil, fmt.Errorf("change files needed") } @@ -163,7 +162,7 @@ func Validate(parsed types.ParsedOptions, validateOpts ValidateOptions) (*Valida } } - fmt.Println() + logging.Info.Println() return result, nil } From 89a0396d9046afbf1a5e8351a7fdc991c46e36cb Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Fri, 6 Mar 2026 17:39:33 -0800 Subject: [PATCH 16/38] config etc --- CLAUDE.md | 3 ++- beachball.config.js | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 80e165377..74d282ddf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -172,6 +172,7 @@ Five-pass algorithm that calculates version changes: - E2E tests: `__e2e__/` directories - 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 ## Configuration @@ -196,7 +197,7 @@ The repo uses `beachball.config.js` with: - Package manager is auto-detected (supports npm, yarn, pnpm) - Pre/post hooks available: `prebump`, `postbump`, `prepublish`, `postpublish`, `precommit` -## Rust and Go Implementations +## Experimental: Rust and Go Implementations The `rust/` and `go/` directories contain parallel re-implementations of beachball's `check` and `change` commands. Both pass 16 tests covering changed package detection, validation, and change file creation. diff --git a/beachball.config.js b/beachball.config.js index c81988d4b..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', From 833af9b2c6d069e76310900ba9a82d8446e24390 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Fri, 6 Mar 2026 17:50:03 -0800 Subject: [PATCH 17/38] rust logging framework --- go/internal/testutil/capture_logging.go | 17 +++++ rust/src/changefile/changed_packages.rs | 17 +++-- rust/src/changefile/read_change_files.rs | 5 +- rust/src/changefile/write_change_files.rs | 3 +- rust/src/commands/change.rs | 3 +- rust/src/commands/check.rs | 3 +- rust/src/git/ensure_shared_history.rs | 5 +- rust/src/logging.rs | 93 +++++++++++++++++++++++ rust/src/monorepo/filter_ignored.rs | 3 +- rust/src/validation/validate.rs | 21 ++--- rust/src/validation/validators.rs | 7 +- rust/tests/common/mod.rs | 18 +++++ 12 files changed, 166 insertions(+), 29 deletions(-) create mode 100644 go/internal/testutil/capture_logging.go diff --git a/go/internal/testutil/capture_logging.go b/go/internal/testutil/capture_logging.go new file mode 100644 index 000000000..00c37786e --- /dev/null +++ b/go/internal/testutil/capture_logging.go @@ -0,0 +1,17 @@ +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) + t.Cleanup(logging.Reset) + return &buf +} diff --git a/rust/src/changefile/changed_packages.rs b/rust/src/changefile/changed_packages.rs index 963f78d2e..be7f33317 100644 --- a/rust/src/changefile/changed_packages.rs +++ b/rust/src/changefile/changed_packages.rs @@ -2,6 +2,7 @@ use anyhow::Result; use std::collections::HashSet; use std::path::Path; +use crate::log_info; use crate::git::commands; use crate::git::ensure_shared_history::ensure_shared_history; use crate::monorepo::filter_ignored::filter_ignored_files; @@ -69,7 +70,7 @@ fn get_all_changed_packages( // If --all, return all in-scope non-private packages if options.all { if verbose { - eprintln!( + log_info!( "--all option was provided, so including all packages that are in scope (regardless of changes)" ); } @@ -84,7 +85,7 @@ fn get_all_changed_packages( return Ok(result); } - println!("Checking for changes against \"{}\"", options.branch); + log_info!("Checking for changes against \"{}\"", options.branch); ensure_shared_history(options)?; @@ -101,7 +102,7 @@ fn get_all_changed_packages( if verbose { let count = changes.len(); - println!( + log_info!( "Found {} changed file{} in current branch (before filtering)", count, if count == 1 { "" } else { "s" } @@ -142,7 +143,7 @@ fn get_all_changed_packages( if non_ignored.is_empty() { if verbose { - eprintln!("All files were ignored"); + log_info!("All files were ignored"); } return Ok(vec![]); } @@ -170,20 +171,20 @@ fn get_all_changed_packages( if !included { if verbose { - eprintln!(" - ~~{file}~~ ({reason})"); + log_info!(" - ~~{file}~~ ({reason})"); } } else { included_packages.insert(pkg_info.unwrap().name.clone()); file_count += 1; if verbose { - eprintln!(" - {file}"); + log_info!(" - {file}"); } } } if verbose { let pkg_count = included_packages.len(); - println!( + log_info!( "Found {} file{} in {} package{} that should be published", file_count, if file_count == 1 { "" } else { "s" }, @@ -242,7 +243,7 @@ pub fn get_changed_packages( if !existing_packages.is_empty() { let mut sorted: Vec<&String> = existing_packages.iter().collect(); sorted.sort(); - println!( + 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::>()) ); diff --git a/rust/src/changefile/read_change_files.rs b/rust/src/changefile/read_change_files.rs index f4283337e..653019fb6 100644 --- a/rust/src/changefile/read_change_files.rs +++ b/rust/src/changefile/read_change_files.rs @@ -1,5 +1,6 @@ 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}; @@ -55,7 +56,7 @@ pub fn read_change_files( let contents = match std::fs::read_to_string(&file_path) { Ok(c) => c, Err(e) => { - eprintln!("WARN: Error reading change file {filename}: {e}"); + log_warn!("Error reading change file {filename}: {e}"); continue; } }; @@ -67,7 +68,7 @@ pub fn read_change_files( } else if let Ok(single) = serde_json::from_str::(&contents) { vec![single] } else { - eprintln!("WARN: Could not parse change file {filename}"); + log_warn!("Could not parse change file {filename}"); continue; }; diff --git a/rust/src/changefile/write_change_files.rs b/rust/src/changefile/write_change_files.rs index 51f295e94..a55e974fb 100644 --- a/rust/src/changefile/write_change_files.rs +++ b/rust/src/changefile/write_change_files.rs @@ -1,6 +1,7 @@ use anyhow::Result; use std::path::Path; +use crate::log_info; use crate::git::commands; use crate::types::change_info::ChangeFileInfo; use crate::types::options::BeachballOptions; @@ -70,7 +71,7 @@ pub fn write_change_files( } } - println!( + log_info!( "git {} these change files:{}", if options.commit { "committed" diff --git a/rust/src/commands/change.rs b/rust/src/commands/change.rs index 22d642478..49489baa3 100644 --- a/rust/src/commands/change.rs +++ b/rust/src/commands/change.rs @@ -1,5 +1,6 @@ use anyhow::{Result, bail}; +use crate::log_info; use crate::changefile::changed_packages::get_changed_packages; use crate::changefile::write_change_files::write_change_files; use crate::git::commands::get_user_email; @@ -28,7 +29,7 @@ pub fn change(parsed: &ParsedOptions) -> Result<()> { )?; if !is_change_needed && options.package.is_none() { - println!("No change files are needed"); + log_info!("No change files are needed"); return Ok(()); } diff --git a/rust/src/commands/check.rs b/rust/src/commands/check.rs index 5a6224e1c..1439ced89 100644 --- a/rust/src/commands/check.rs +++ b/rust/src/commands/check.rs @@ -1,5 +1,6 @@ use anyhow::Result; +use crate::log_info; use crate::types::options::ParsedOptions; use crate::validation::validate::{ValidateOptions, validate}; @@ -13,6 +14,6 @@ pub fn check(parsed: &ParsedOptions) -> Result<()> { ..Default::default() }, )?; - println!("No change files are needed"); + log_info!("No change files are needed"); Ok(()) } diff --git a/rust/src/git/ensure_shared_history.rs b/rust/src/git/ensure_shared_history.rs index decd926f0..5f9407a5e 100644 --- a/rust/src/git/ensure_shared_history.rs +++ b/rust/src/git/ensure_shared_history.rs @@ -1,5 +1,6 @@ use anyhow::{Result, bail}; +use crate::log_info; use crate::types::options::BeachballOptions; use super::commands; @@ -30,7 +31,7 @@ pub fn ensure_shared_history(options: &BeachballOptions) -> Result<()> { // Fetch the branch if options.verbose { - eprintln!("Fetching {branch_name} from {remote}..."); + log_info!("Fetching {branch_name} from {remote}..."); } commands::fetch(&remote, &branch_name, cwd, options.depth)?; @@ -43,7 +44,7 @@ pub fn ensure_shared_history(options: &BeachballOptions) -> Result<()> { } current_depth *= 2; if options.verbose { - eprintln!("Deepening fetch to {current_depth}..."); + log_info!("Deepening fetch to {current_depth}..."); } commands::fetch(&remote, &branch_name, cwd, Some(current_depth))?; } diff --git a/rust/src/logging.rs b/rust/src/logging.rs index dd13a3158..7775a8ec5 100644 --- a/rust/src/logging.rs +++ b/rust/src/logging.rs @@ -1,3 +1,96 @@ +// User-facing logging with test capture support. +// +// All CLI output goes through log_info!/log_warn!/log_error! 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) }; +} + +/// Start capturing log output on the current thread. +pub fn set_output() { + LOG_CAPTURE.with(|c| *c.borrow_mut() = Some(Vec::new())); +} + +/// Stop capturing and restore default stdout/stderr output. +pub fn reset() { + LOG_CAPTURE.with(|c| *c.borrow_mut() = None); +} + +/// 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).to_string(), + None => String::new(), + } + }) +} + +pub enum Level { + Info, + Warn, + Error, +} + +pub fn write_log(level: Level, msg: &str) { + LOG_CAPTURE.with(|c| { + let mut borrow = c.borrow_mut(); + if let Some(ref mut buf) = *borrow { + match level { + Level::Info => 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 => 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)*)) + }; +} + /// Format items as a bulleted list. pub fn bulleted_list(items: &[&str]) -> String { items diff --git a/rust/src/monorepo/filter_ignored.rs b/rust/src/monorepo/filter_ignored.rs index 71970cc7e..8c951d87c 100644 --- a/rust/src/monorepo/filter_ignored.rs +++ b/rust/src/monorepo/filter_ignored.rs @@ -1,3 +1,4 @@ +use crate::log_info; use super::path_included::match_with_base; /// Filter out file paths that match any of the ignore patterns. @@ -13,7 +14,7 @@ pub fn filter_ignored_files( for pattern in ignore_patterns { if match_with_base(path, pattern) { if verbose { - eprintln!(" - ~~{path}~~ (ignored by pattern \"{pattern}\")"); + log_info!(" - ~~{path}~~ (ignored by pattern \"{pattern}\")"); } return false; } diff --git a/rust/src/validation/validate.rs b/rust/src/validation/validate.rs index 2dbc9c6ff..e21109c92 100644 --- a/rust/src/validation/validate.rs +++ b/rust/src/validation/validate.rs @@ -1,5 +1,6 @@ use anyhow::Result; +use crate::{log_error, log_info, log_warn}; 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; @@ -47,7 +48,7 @@ impl std::error::Error for ValidationError {} /// Log a validation error and set the flag. fn log_validation_error(msg: &str, has_error: &mut bool) { - eprintln!("ERROR: {msg}"); + log_error!("{msg}"); *has_error = true; } @@ -58,15 +59,15 @@ pub fn validate( ) -> Result { let options = &parsed.options; - println!("\nValidating options and change files..."); + 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() { - eprintln!( - "WARN: There are untracked changes in your repository:\n{}", + log_warn!( + "There are untracked changes in your repository:\n{}", bulleted_list(&untracked.iter().map(|s| s.as_str()).collect::>()) ); } @@ -222,15 +223,15 @@ pub fn validate( }; let mut sorted = pkgs.clone(); sorted.sort(); - println!( + log_info!( "{message}:\n{}", bulleted_list(&sorted.iter().map(|s| s.as_str()).collect::>()) ); } if is_change_needed && !validate_options.allow_missing_change_files { - eprintln!("ERROR: Change files are needed!"); - println!("{}", options.changehint); + log_error!("Change files are needed!"); + log_info!("{}", options.changehint); return Err(ValidationError { message: "Change files are needed".to_string(), } @@ -238,7 +239,7 @@ pub fn validate( } if options.disallow_deleted_change_files && are_change_files_deleted(options) { - eprintln!("ERROR: Change files must not be deleted!"); + log_error!("Change files must not be deleted!"); return Err(ValidationError { message: "Change files must not be deleted".to_string(), } @@ -254,10 +255,10 @@ pub fn validate( && !change_set.is_empty() && options.verbose { - println!("(Skipping package dependency validation — not implemented in Rust port)"); + log_info!("(Skipping package dependency validation — not implemented in Rust port)"); } - println!(); + log_info!(); Ok(ValidationResult { is_change_needed, diff --git a/rust/src/validation/validators.rs b/rust/src/validation/validators.rs index 181611015..4d8bca25d 100644 --- a/rust/src/validation/validators.rs +++ b/rust/src/validation/validators.rs @@ -1,3 +1,4 @@ +use crate::log_error; use crate::types::change_info::ChangeType; use crate::types::options::VersionGroupOptions; use crate::types::package_info::{PackageGroups, PackageInfos}; @@ -38,7 +39,7 @@ pub fn is_valid_group_options(groups: &[VersionGroupOptions]) -> bool { let mut valid = true; for group in groups { if group.name.is_empty() { - eprintln!("ERROR: Group option is missing 'name'"); + log_error!("Group option is missing 'name'"); valid = false; } } @@ -57,8 +58,8 @@ pub fn is_valid_grouped_package_options( && let Some(ref opts) = info.package_options && opts.disallowed_change_types.is_some() { - eprintln!( - "ERROR: Package \"{pkg_name}\" has disallowedChangeTypes but is in a group. \ + log_error!( + "Package \"{pkg_name}\" has disallowedChangeTypes but is in a group. \ Group-level disallowedChangeTypes take precedence." ); valid = false; diff --git a/rust/tests/common/mod.rs b/rust/tests/common/mod.rs index 4e0ef6fb1..d40044df9 100644 --- a/rust/tests/common/mod.rs +++ b/rust/tests/common/mod.rs @@ -14,6 +14,24 @@ 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. +#[allow(dead_code)] +pub fn capture_logging() { + beachball::logging::set_output(); +} + +/// 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)] From 21e6e9d8a835a92f07bf186fb5a9a5aa188ffe67 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Tue, 10 Mar 2026 13:53:50 -0700 Subject: [PATCH 18/38] rust log tests and format --- rust/src/changefile/changed_packages.rs | 2 +- rust/src/changefile/write_change_files.rs | 2 +- rust/src/commands/change.rs | 2 +- rust/src/monorepo/filter_ignored.rs | 2 +- rust/src/validation/validate.rs | 2 +- rust/tests/change_test.rs | 17 ++++++++++++- rust/tests/changed_packages_test.rs | 31 +++++++++++++++++++++-- rust/tests/validate_test.rs | 14 ++++++++++ 8 files changed, 64 insertions(+), 8 deletions(-) diff --git a/rust/src/changefile/changed_packages.rs b/rust/src/changefile/changed_packages.rs index be7f33317..3b2ecb7d1 100644 --- a/rust/src/changefile/changed_packages.rs +++ b/rust/src/changefile/changed_packages.rs @@ -2,9 +2,9 @@ use anyhow::Result; use std::collections::HashSet; use std::path::Path; -use crate::log_info; use crate::git::commands; use crate::git::ensure_shared_history::ensure_shared_history; +use crate::log_info; use crate::monorepo::filter_ignored::filter_ignored_files; use crate::types::change_info::{ChangeFileInfo, ChangeInfoMultiple}; use crate::types::options::BeachballOptions; diff --git a/rust/src/changefile/write_change_files.rs b/rust/src/changefile/write_change_files.rs index a55e974fb..22140c33d 100644 --- a/rust/src/changefile/write_change_files.rs +++ b/rust/src/changefile/write_change_files.rs @@ -1,8 +1,8 @@ use anyhow::Result; use std::path::Path; -use crate::log_info; use crate::git::commands; +use crate::log_info; use crate::types::change_info::ChangeFileInfo; use crate::types::options::BeachballOptions; diff --git a/rust/src/commands/change.rs b/rust/src/commands/change.rs index 49489baa3..30f6d886b 100644 --- a/rust/src/commands/change.rs +++ b/rust/src/commands/change.rs @@ -1,9 +1,9 @@ use anyhow::{Result, bail}; -use crate::log_info; 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}; diff --git a/rust/src/monorepo/filter_ignored.rs b/rust/src/monorepo/filter_ignored.rs index 8c951d87c..3b8b99c90 100644 --- a/rust/src/monorepo/filter_ignored.rs +++ b/rust/src/monorepo/filter_ignored.rs @@ -1,5 +1,5 @@ -use crate::log_info; use super::path_included::match_with_base; +use crate::log_info; /// Filter out file paths that match any of the ignore patterns. /// Uses matchBase: true behavior (patterns without '/' match against basename). diff --git a/rust/src/validation/validate.rs b/rust/src/validation/validate.rs index e21109c92..8f4167660 100644 --- a/rust/src/validation/validate.rs +++ b/rust/src/validation/validate.rs @@ -1,6 +1,5 @@ use anyhow::Result; -use crate::{log_error, log_info, log_warn}; 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; @@ -14,6 +13,7 @@ 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 { diff --git a/rust/tests/change_test.rs b/rust/tests/change_test.rs index f49cee729..8795879d9 100644 --- a/rust/tests/change_test.rs +++ b/rust/tests/change_test.rs @@ -6,7 +6,9 @@ use beachball::types::change_info::{ChangeFileInfo, ChangeInfoMultiple, ChangeTy 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}; +use common::{ + DEFAULT_BRANCH, DEFAULT_REMOTE_BRANCH, capture_logging, get_log_output, reset_logging, +}; fn make_cli(message: &str, change_type: ChangeType) -> CliOptions { CliOptions { @@ -31,10 +33,15 @@ fn does_not_create_change_files_when_no_changes() { 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")); } #[test] @@ -83,8 +90,11 @@ fn creates_and_stages_a_change_file() { ..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(); @@ -100,6 +110,7 @@ fn creates_and_stages_a_change_file() { 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:")); } #[test] @@ -109,9 +120,12 @@ fn creates_and_commits_a_change_file() { 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(); @@ -123,6 +137,7 @@ fn creates_and_commits_a_change_file() { 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:")); } #[test] diff --git a/rust/tests/changed_packages_test.rs b/rust/tests/changed_packages_test.rs index dad59d502..a74afde31 100644 --- a/rust/tests/changed_packages_test.rs +++ b/rust/tests/changed_packages_test.rs @@ -7,7 +7,9 @@ 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}; +use common::{ + DEFAULT_BRANCH, DEFAULT_REMOTE_BRANCH, capture_logging, get_log_output, reset_logging, +}; use serde_json::json; use std::collections::HashMap; @@ -55,9 +57,18 @@ fn returns_package_name_when_changes_in_branch() { check_out_test_branch(&repo, "changes_in_branch"); repo.commit_change("packages/foo/myFilename"); - let (options, infos, scoped) = get_options_and_packages(&repo, None, None); + let opts = BeachballOptions { + verbose: true, + ..Default::default() + }; + capture_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")); } #[test] @@ -154,8 +165,13 @@ fn respects_ignore_patterns() { repo.write_file_content("yarn.lock", "changed"); repo.git(&["add", "-A"]); + capture_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")); } // ===== Monorepo tests ===== @@ -191,8 +207,13 @@ fn excludes_packages_with_existing_change_files() { let (options, infos, scoped) = get_options_and_packages(&repo, Some(opts), None); generate_change_files(&["foo"], &options, &repo); + capture_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")); // Change bar => bar is the only changed package returned repo.stage_change("packages/bar/test.js"); @@ -249,9 +270,15 @@ fn ignores_package_changes_as_appropriate() { ..Default::default() }; + capture_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")); } #[test] diff --git a/rust/tests/validate_test.rs b/rust/tests/validate_test.rs index 6160c73bc..44318d16b 100644 --- a/rust/tests/validate_test.rs +++ b/rust/tests/validate_test.rs @@ -6,6 +6,7 @@ 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, @@ -26,6 +27,7 @@ fn succeeds_with_no_changes() { let repo = factory.clone_repository(); repo.checkout(&["-b", "test"]); + capture_logging(); let result = validate_wrapper( &repo, ValidateOptions { @@ -33,9 +35,12 @@ fn succeeds_with_no_changes() { ..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...")); } #[test] @@ -45,6 +50,7 @@ fn exits_with_error_if_change_files_needed() { repo.checkout(&["-b", "test"]); repo.stage_change("packages/foo/test.js"); + capture_logging(); let result = validate_wrapper( &repo, ValidateOptions { @@ -52,9 +58,13 @@ fn exits_with_error_if_change_files_needed() { ..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")); } #[test] @@ -64,6 +74,7 @@ fn returns_without_error_if_allow_missing_change_files() { repo.checkout(&["-b", "test"]); repo.stage_change("packages/foo/test.js"); + capture_logging(); let result = validate_wrapper( &repo, ValidateOptions { @@ -72,7 +83,10 @@ fn returns_without_error_if_allow_missing_change_files() { ..Default::default() }, ); + let output = get_log_output(); + reset_logging(); assert!(result.is_ok()); assert!(result.unwrap().is_change_needed); + assert!(!output.contains("ERROR:")); } From 0bb3d162e893a644a547438ba753bad4af4d596f Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Tue, 10 Mar 2026 13:55:41 -0700 Subject: [PATCH 19/38] go logging and log tests --- .claude/settings.json | 7 +++++++ go/internal/changefile/changed_packages_test.go | 13 ++++++++++++- go/internal/changefile/write_change_files.go | 13 ++++++++++--- go/internal/commands/change.go | 2 +- go/internal/commands/change_test.go | 4 ++++ go/internal/commands/check.go | 2 +- go/internal/validation/validate_test.go | 7 +++++++ 7 files changed, 42 insertions(+), 6 deletions(-) create mode 100644 .claude/settings.json 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/go/internal/changefile/changed_packages_test.go b/go/internal/changefile/changed_packages_test.go index f69118008..a3ed76586 100644 --- a/go/internal/changefile/changed_packages_test.go +++ b/go/internal/changefile/changed_packages_test.go @@ -64,10 +64,14 @@ func TestReturnsPackageNameWhenChangesInBranch(t *testing.T) { checkOutTestBranch(repo, "changes_in_branch") repo.CommitChange("packages/foo/myFilename") - opts, infos, scoped := getOptionsAndPackages(t, repo, nil, nil) + 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") } func TestReturnsEmptyListForChangelogChanges(t *testing.T) { @@ -144,9 +148,11 @@ func TestRespectsIgnorePatterns(t *testing.T) { repo.WriteFileContent("yarn.lock", "changed") repo.Git([]string{"add", "-A"}) + buf := testutil.CaptureLogging(t) result, err := changefile.GetChangedPackages(&opts, infos, scoped) require.NoError(t, err) assert.Empty(t, result) + assert.Contains(t, buf.String(), "ignored by pattern") } // ===== Monorepo tests ===== @@ -177,9 +183,11 @@ func TestExcludesPackagesWithExistingChangeFiles(t *testing.T) { opts, infos, scoped := getOptionsAndPackages(t, repo, &overrides, nil) testutil.GenerateChangeFiles(t, []string{"foo"}, &opts, repo) + buf := testutil.CaptureLogging(t) result, err := changefile.GetChangedPackages(&opts, infos, scoped) require.NoError(t, err) assert.Empty(t, result) + assert.Contains(t, buf.String(), "already has change files for these packages") // Change bar => bar is the only changed package returned repo.StageChange("packages/bar/test.js") @@ -226,10 +234,13 @@ func TestIgnoresPackageChangesAsAppropriate(t *testing.T) { overrides.IgnorePatterns = []string{"**/jest.config.js"} overrides.Verbose = true + buf := testutil.CaptureLogging(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) + assert.Contains(t, buf.String(), "is private") + assert.Contains(t, buf.String(), "is out of scope") } func TestDetectsChangedFilesInMultiRootMonorepo(t *testing.T) { diff --git a/go/internal/changefile/write_change_files.go b/go/internal/changefile/write_change_files.go index b2edfcd31..b8eeb8542 100644 --- a/go/internal/changefile/write_change_files.go +++ b/go/internal/changefile/write_change_files.go @@ -41,7 +41,6 @@ func WriteChangeFiles(options *types.BeachballOptions, changes []types.ChangeFil } filePaths = append(filePaths, filePath) - logging.Info.Printf("Wrote change file: %s", filename) } else { for _, change := range changes { id := uuid.New().String() @@ -59,7 +58,6 @@ func WriteChangeFiles(options *types.BeachballOptions, changes []types.ChangeFil } filePaths = append(filePaths, filePath) - logging.Info.Printf("Wrote change file: %s", filename) } } @@ -73,8 +71,17 @@ func WriteChangeFiles(options *types.BeachballOptions, changes []types.ChangeFil if err := git.Commit(msg, options.Path); err != nil { return fmt.Errorf("failed to commit change files: %w", err) } - logging.Info.Println("Committed change files") } + + action := "staged" + if options.Commit { + action = "committed" + } + fileList := "" + for _, f := range filePaths { + fileList += fmt.Sprintf("\n - %s", f) + } + logging.Info.Printf("git %s these change files:%s", action, fileList) } return nil diff --git a/go/internal/commands/change.go b/go/internal/commands/change.go index 0a65909f6..96223701f 100644 --- a/go/internal/commands/change.go +++ b/go/internal/commands/change.go @@ -21,7 +21,7 @@ func Change(parsed types.ParsedOptions) error { } if !result.IsChangeNeeded && len(parsed.Options.Package) == 0 { - logging.Info.Println("No changes detected; no change files are needed.") + logging.Info.Println("No change files are needed") return nil } diff --git a/go/internal/commands/change_test.go b/go/internal/commands/change_test.go index 7d2f06732..862e1eba9 100644 --- a/go/internal/commands/change_test.go +++ b/go/internal/commands/change_test.go @@ -28,6 +28,7 @@ func TestDoesNotCreateChangeFilesWhenNoChanges(t *testing.T) { repo := factory.CloneRepository() repo.Checkout("-b", "no-changes-test", testutil.DefaultBranch) + buf := testutil.CaptureLogging(t) repoOpts := getDefaultOptions() cli := types.CliOptions{ @@ -42,6 +43,7 @@ func TestDoesNotCreateChangeFilesWhenNoChanges(t *testing.T) { files := testutil.GetChangeFiles(&parsed.Options) assert.Empty(t, files) + assert.Contains(t, buf.String(), "No change files are needed") } func TestCreatesChangeFileWithTypeAndMessage(t *testing.T) { @@ -121,6 +123,7 @@ func TestCreatesAndCommitsChangeFile(t *testing.T) { repo.Checkout("-b", "commits-change-test", testutil.DefaultBranch) repo.CommitChange("file.js") + buf := testutil.CaptureLogging(t) repoOpts := getDefaultOptions() cli := types.CliOptions{ @@ -144,6 +147,7 @@ func TestCreatesAndCommitsChangeFile(t *testing.T) { var change types.ChangeFileInfo json.Unmarshal(data, &change) assert.Equal(t, "commit me please", change.Comment) + assert.Contains(t, buf.String(), "git committed these change files:") } func TestCreatesAndCommitsChangeFileWithChangeDir(t *testing.T) { diff --git a/go/internal/commands/check.go b/go/internal/commands/check.go index 2ba257600..6d5be0634 100644 --- a/go/internal/commands/check.go +++ b/go/internal/commands/check.go @@ -15,6 +15,6 @@ func Check(parsed types.ParsedOptions) error { return err } - logging.Info.Println("No change files are needed!") + logging.Info.Println("No change files are needed") return nil } diff --git a/go/internal/validation/validate_test.go b/go/internal/validation/validate_test.go index d15ac8ea7..97739bd95 100644 --- a/go/internal/validation/validate_test.go +++ b/go/internal/validation/validate_test.go @@ -25,6 +25,7 @@ func TestSucceedsWithNoChanges(t *testing.T) { repo := factory.CloneRepository() repo.Checkout("-b", "test", testutil.DefaultBranch) + buf := testutil.CaptureLogging(t) repoOpts := getDefaultOptions() parsed := options.GetParsedOptionsForTest(repo.RootPath(), types.CliOptions{}, repoOpts) @@ -33,6 +34,7 @@ func TestSucceedsWithNoChanges(t *testing.T) { }) require.NoError(t, err) assert.False(t, result.IsChangeNeeded) + assert.Contains(t, buf.String(), "Validating options and change files...") } func TestExitsWithErrorIfChangeFilesNeeded(t *testing.T) { @@ -41,6 +43,7 @@ func TestExitsWithErrorIfChangeFilesNeeded(t *testing.T) { 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) @@ -48,6 +51,8 @@ func TestExitsWithErrorIfChangeFilesNeeded(t *testing.T) { 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") } func TestReturnsWithoutErrorIfAllowMissingChangeFiles(t *testing.T) { @@ -56,6 +61,7 @@ func TestReturnsWithoutErrorIfAllowMissingChangeFiles(t *testing.T) { 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) @@ -65,4 +71,5 @@ func TestReturnsWithoutErrorIfAllowMissingChangeFiles(t *testing.T) { }) require.NoError(t, err) assert.True(t, result.IsChangeNeeded) + assert.NotContains(t, buf.String(), "ERROR:") } From acc42a2d1fa8e2681a8c1e057c94ad80eb1dc93a Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Tue, 10 Mar 2026 17:20:30 -0700 Subject: [PATCH 20/38] go fixes --- CLAUDE.md | 6 + go/cmd/beachball/main.go | 1 + go/internal/changefile/change_types.go | 29 ---- go/internal/changefile/change_types_test.go | 91 ------------ .../changefile/disallowed_change_types.go | 32 +++++ .../disallowed_change_types_test.go | 135 ++++++++++++++++++ go/internal/options/get_options.go | 6 + go/internal/options/repo_options.go | 1 + go/internal/types/options.go | 44 +++--- go/internal/types/package_info.go | 7 - go/internal/validation/validators.go | 2 +- .../getDisallowedChangeTypes.test.ts | 34 +++++ 12 files changed, 244 insertions(+), 144 deletions(-) delete mode 100644 go/internal/changefile/change_types.go delete mode 100644 go/internal/changefile/change_types_test.go create mode 100644 go/internal/changefile/disallowed_change_types.go create mode 100644 go/internal/changefile/disallowed_change_types_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 74d282ddf..f394cb090 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -219,6 +219,12 @@ Both implement: CLI parsing, JSON config loading (`.beachballrc.json` and `packa Not implemented: JS config files, interactive prompts, `bumpInMemory`, publish/bump/changelog, pnpm/rush/lerna workspaces. +### Requirements + +The behavior and tests as specified in the TypeScript code must be matched exactly in the Go/Rust code. Do not change behavior 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 implementing it. + +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. + ### Structure - **Rust**: `src/` with nested modules (`types/`, `options/`, `git/`, `monorepo/`, `changefile/`, `validation/`, `commands/`), integration tests in `tests/` with shared helpers in `tests/common/` diff --git a/go/cmd/beachball/main.go b/go/cmd/beachball/main.go index d2e00eccc..e5dc80908 100644 --- a/go/cmd/beachball/main.go +++ b/go/cmd/beachball/main.go @@ -54,6 +54,7 @@ func main() { } 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 diff --git a/go/internal/changefile/change_types.go b/go/internal/changefile/change_types.go deleted file mode 100644 index 3fb3c1fd7..000000000 --- a/go/internal/changefile/change_types.go +++ /dev/null @@ -1,29 +0,0 @@ -package changefile - -import "github.com/microsoft/beachball/internal/types" - -// GetDisallowedChangeTypes returns the disallowed change types for a package. -func GetDisallowedChangeTypes( - pkgName string, - packageInfos types.PackageInfos, - packageGroups types.PackageGroups, - options *types.BeachballOptions, -) []string { - // Check package-level disallowed types - if info, ok := packageInfos[pkgName]; ok && info.PackageOptions != nil { - if len(info.PackageOptions.DisallowedChangeTypes) > 0 { - return info.PackageOptions.DisallowedChangeTypes - } - } - - // Check group-level disallowed types - for _, group := range packageGroups { - for _, name := range group.Packages { - if name == pkgName && len(group.DisallowedChangeTypes) > 0 { - return group.DisallowedChangeTypes - } - } - } - - return nil -} diff --git a/go/internal/changefile/change_types_test.go b/go/internal/changefile/change_types_test.go deleted file mode 100644 index 88c648ac6..000000000 --- a/go/internal/changefile/change_types_test.go +++ /dev/null @@ -1,91 +0,0 @@ -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() - -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) -} - -func TestGetDisallowedChangeTypes_ReturnsNilWhenNoSettings(t *testing.T) { - infos := testutil.MakePackageInfosSimple(testRoot, "foo") - groups := types.PackageGroups{} - opts := &types.BeachballOptions{} - - result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) - assert.Nil(t, result) -} - -func TestGetDisallowedChangeTypes_ReturnsPackageLevelDisallowedTypes(t *testing.T) { - infos := testutil.MakePackageInfosSimple(testRoot, "foo") - infos["foo"].PackageOptions = &types.PackageOptions{ - DisallowedChangeTypes: []string{"major"}, - } - groups := types.PackageGroups{} - opts := &types.BeachballOptions{} - - result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) - assert.Equal(t, []string{"major"}, result) -} - -func TestGetDisallowedChangeTypes_ReturnsGroupLevelDisallowedTypes(t *testing.T) { - infos := testutil.MakePackageInfosSimple(testRoot, "foo") - groups := types.PackageGroups{ - "grp1": &types.PackageGroup{ - Name: "grp1", - Packages: []string{"foo"}, - DisallowedChangeTypes: []string{"major", "minor"}, - }, - } - opts := &types.BeachballOptions{} - - result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) - assert.Equal(t, []string{"major", "minor"}, result) -} - -func TestGetDisallowedChangeTypes_ReturnsNilIfNotInGroup(t *testing.T) { - infos := testutil.MakePackageInfosSimple(testRoot, "bar") - groups := types.PackageGroups{ - "grp1": &types.PackageGroup{ - Name: "grp1", - Packages: []string{"foo"}, - DisallowedChangeTypes: []string{"major"}, - }, - } - opts := &types.BeachballOptions{} - - result := changefile.GetDisallowedChangeTypes("bar", infos, groups, opts) - assert.Nil(t, result) -} - -func TestGetDisallowedChangeTypes_PrefersPackageOverGroup(t *testing.T) { - infos := testutil.MakePackageInfosSimple(testRoot, "foo") - infos["foo"].PackageOptions = &types.PackageOptions{ - DisallowedChangeTypes: []string{"major"}, - } - groups := types.PackageGroups{ - "grp1": &types.PackageGroup{ - Name: "grp1", - Packages: []string{"foo"}, - DisallowedChangeTypes: []string{"minor"}, - }, - } - opts := &types.BeachballOptions{} - - // The implementation checks package-level first, so it should return "major" - result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) - assert.Equal(t, []string{"major"}, result) -} diff --git a/go/internal/changefile/disallowed_change_types.go b/go/internal/changefile/disallowed_change_types.go new file mode 100644 index 000000000..eb71b7146 --- /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, +) []string { + // 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..600e5908f --- /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() + +// 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) +} + +// 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 = []string{"major"} + + result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) + assert.Equal(t, []string{"major"}, result) +} + +// returns disallowedChangeTypes for package +func TestGetDisallowedChangeTypes_ReturnsPackageLevelDisallowedTypes(t *testing.T) { + infos := testutil.MakePackageInfosSimple(testRoot, "foo") + infos["foo"].PackageOptions = &types.PackageOptions{ + DisallowedChangeTypes: []string{"major", "minor"}, + } + groups := types.PackageGroups{} + opts := &types.BeachballOptions{} + + result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) + assert.Equal(t, []string{"major", "minor"}, result) +} + +// Not possible (Go doesn't distinguish between null and unset): +// returns null if package disallowedChangeTypes is set to null + +// 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: []string{}, + } + groups := types.PackageGroups{} + opts := &types.BeachballOptions{} + + result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) + assert.Equal(t, []string{}, result) +} + +// 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: []string{"major", "minor"}, + }, + } + opts := &types.BeachballOptions{} + + result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) + assert.Equal(t, []string{"major", "minor"}, result) +} + +// Not possible (Go doesn't distinguish between null and unset): +// returns null if package group disallowedChangeTypes is set to null + +// 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: []string{}, + }, + } + opts := &types.BeachballOptions{} + + result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) + assert.Equal(t, []string{}, result) +} + +// 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: []string{"patch"}, + } + groups := types.PackageGroups{ + "grp1": &types.PackageGroup{ + Name: "grp1", + Packages: []string{"bar"}, + DisallowedChangeTypes: []string{"major", "minor"}, + }, + } + opts := &types.BeachballOptions{} + + result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) + assert.Equal(t, []string{"patch"}, result) +} + +// prefers disallowedChangeTypes for group over package +func TestGetDisallowedChangeTypes_PrefersGroupOverPackage(t *testing.T) { + infos := testutil.MakePackageInfosSimple(testRoot, "foo") + infos["foo"].PackageOptions = &types.PackageOptions{ + DisallowedChangeTypes: []string{"patch"}, + } + groups := types.PackageGroups{ + "grp1": &types.PackageGroup{ + Name: "grp1", + Packages: []string{"foo"}, + DisallowedChangeTypes: []string{"major", "minor"}, + }, + } + opts := &types.BeachballOptions{} + + result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) + assert.Equal(t, []string{"major", "minor"}, result) +} diff --git a/go/internal/options/get_options.go b/go/internal/options/get_options.go index 34eceab0a..61264fd7c 100644 --- a/go/internal/options/get_options.go +++ b/go/internal/options/get_options.go @@ -107,6 +107,9 @@ func applyRepoConfig(opts *types.BeachballOptions, cfg *RepoConfig) { if cfg.DependentChangeType != "" { opts.DependentChangeType = cfg.DependentChangeType } + if cfg.DisallowedChangeTypes != nil { + opts.DisallowedChangeTypes = cfg.DisallowedChangeTypes + } if cfg.DisallowDeletedChangeFiles != nil { opts.DisallowDeletedChangeFiles = *cfg.DisallowDeletedChangeFiles } @@ -143,6 +146,9 @@ func applyCliOptions(opts *types.BeachballOptions, cli *types.CliOptions) { if cli.Commit != nil { opts.Commit = *cli.Commit } + if cli.DependentChangeType != "" { + opts.DependentChangeType = cli.DependentChangeType + } if cli.Fetch != nil { opts.Fetch = *cli.Fetch } diff --git a/go/internal/options/repo_options.go b/go/internal/options/repo_options.go index a4908ff4f..480e97c1b 100644 --- a/go/internal/options/repo_options.go +++ b/go/internal/options/repo_options.go @@ -16,6 +16,7 @@ type RepoConfig struct { 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"` diff --git a/go/internal/types/options.go b/go/internal/types/options.go index 4cb18a437..25654ecce 100644 --- a/go/internal/types/options.go +++ b/go/internal/types/options.go @@ -1,41 +1,52 @@ package types // BeachballOptions holds all beachball configuration. +// TODO: this mixes RepoOptions and merged options type BeachballOptions struct { All bool + AuthType string Branch string - Command string ChangeDir string ChangeHint string + Command string Commit bool DependentChangeType string + DisallowedChangeTypes []string DisallowDeletedChangeFiles bool Fetch bool GroupChanges bool + Groups []VersionGroupOptions IgnorePatterns []string Message string Package []string Path string Scope []string - Type string Token string - AuthType string + Type string Verbose bool - Groups []VersionGroupOptions } // DefaultOptions returns BeachballOptions with sensible defaults. // TODO: better default path value, or require path passed? func DefaultOptions() BeachballOptions { return BeachballOptions{ + AuthType: "authtoken", 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 []string `json:"disallowedChangeTypes,omitempty"` + DefaultNearestBumpType string `json:"defaultNearestBumpType,omitempty"` +} + // VersionGroupOptions configures version groups. type VersionGroupOptions struct { Name string `json:"name"` @@ -46,18 +57,19 @@ type VersionGroupOptions struct { // CliOptions holds CLI-specific options that override config. type CliOptions struct { - All *bool - Branch string - Command string - ChangeType string - Commit *bool - ConfigPath string - Fetch *bool - Message string - Package []string - Path string - Scope []string - Verbose *bool + 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. diff --git a/go/internal/types/package_info.go b/go/internal/types/package_info.go index baca9efcb..0aafdae3c 100644 --- a/go/internal/types/package_info.go +++ b/go/internal/types/package_info.go @@ -10,13 +10,6 @@ type PackageJson struct { Beachball *PackageOptions `json:"beachball,omitempty"` } -// PackageOptions represents beachball-specific options in package.json. -type PackageOptions struct { - ShouldPublish *bool `json:"shouldPublish,omitempty"` - DisallowedChangeTypes []string `json:"disallowedChangeTypes,omitempty"` - DefaultNearestBumpType string `json:"defaultNearestBumpType,omitempty"` -} - // PackageInfo holds information about a single package. type PackageInfo struct { Name string diff --git a/go/internal/validation/validators.go b/go/internal/validation/validators.go index 6272dd1fb..7fea53b3a 100644 --- a/go/internal/validation/validators.go +++ b/go/internal/validation/validators.go @@ -3,7 +3,7 @@ package validation import "github.com/microsoft/beachball/internal/types" var validAuthTypes = map[string]bool{ - "authToken": true, + "authtoken": true, "password": true, } 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'] } }, From 89cba38b19bf0d4f8b835b6b8766818400586ecc Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Tue, 10 Mar 2026 17:27:34 -0700 Subject: [PATCH 21/38] go: pseudo enums --- .../changefile/disallowed_change_types.go | 2 +- .../disallowed_change_types_test.go | 32 +++++----- go/internal/commands/change.go | 11 ++-- go/internal/options/get_options.go | 12 ++-- go/internal/types/change_info.go | 63 ++++++++----------- go/internal/types/options.go | 32 ++++++---- go/internal/types/package_info.go | 2 +- go/internal/validation/validate.go | 22 +++---- go/internal/validation/validators.go | 31 +++++---- 9 files changed, 101 insertions(+), 106 deletions(-) diff --git a/go/internal/changefile/disallowed_change_types.go b/go/internal/changefile/disallowed_change_types.go index eb71b7146..7a293f0ce 100644 --- a/go/internal/changefile/disallowed_change_types.go +++ b/go/internal/changefile/disallowed_change_types.go @@ -13,7 +13,7 @@ func GetDisallowedChangeTypes( packageInfos types.PackageInfos, packageGroups types.PackageGroups, opts *types.BeachballOptions, -) []string { +) []types.ChangeType { // Check group-level disallowed types first (group takes priority) for _, group := range packageGroups { if group.DisallowedChangeTypes != nil && slices.Contains(group.Packages, pkgName) { diff --git a/go/internal/changefile/disallowed_change_types_test.go b/go/internal/changefile/disallowed_change_types_test.go index 600e5908f..e3f6ff612 100644 --- a/go/internal/changefile/disallowed_change_types_test.go +++ b/go/internal/changefile/disallowed_change_types_test.go @@ -26,23 +26,23 @@ func TestGetDisallowedChangeTypes_FallsBackToMainOption(t *testing.T) { infos := testutil.MakePackageInfosSimple(testRoot, "foo") groups := types.PackageGroups{} opts := &types.BeachballOptions{} - opts.DisallowedChangeTypes = []string{"major"} + opts.DisallowedChangeTypes = []types.ChangeType{types.ChangeTypeMajor} result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) - assert.Equal(t, []string{"major"}, result) + assert.Equal(t, []types.ChangeType{types.ChangeTypeMajor}, result) } // returns disallowedChangeTypes for package func TestGetDisallowedChangeTypes_ReturnsPackageLevelDisallowedTypes(t *testing.T) { infos := testutil.MakePackageInfosSimple(testRoot, "foo") infos["foo"].PackageOptions = &types.PackageOptions{ - DisallowedChangeTypes: []string{"major", "minor"}, + DisallowedChangeTypes: []types.ChangeType{types.ChangeTypeMajor, types.ChangeTypeMinor}, } groups := types.PackageGroups{} opts := &types.BeachballOptions{} result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) - assert.Equal(t, []string{"major", "minor"}, result) + assert.Equal(t, []types.ChangeType{types.ChangeTypeMajor, types.ChangeTypeMinor}, result) } // Not possible (Go doesn't distinguish between null and unset): @@ -52,13 +52,13 @@ func TestGetDisallowedChangeTypes_ReturnsPackageLevelDisallowedTypes(t *testing. func TestGetDisallowedChangeTypes_ReturnsEmptyArrayForEmptyPackageDisallowedTypes(t *testing.T) { infos := testutil.MakePackageInfosSimple(testRoot, "foo") infos["foo"].PackageOptions = &types.PackageOptions{ - DisallowedChangeTypes: []string{}, + DisallowedChangeTypes: []types.ChangeType{}, } groups := types.PackageGroups{} opts := &types.BeachballOptions{} result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) - assert.Equal(t, []string{}, result) + assert.Equal(t, []types.ChangeType{}, result) } // returns disallowedChangeTypes for package group @@ -68,13 +68,13 @@ func TestGetDisallowedChangeTypes_ReturnsGroupLevelDisallowedTypes(t *testing.T) "grp1": &types.PackageGroup{ Name: "grp1", Packages: []string{"foo"}, - DisallowedChangeTypes: []string{"major", "minor"}, + DisallowedChangeTypes: []types.ChangeType{types.ChangeTypeMajor, types.ChangeTypeMinor}, }, } opts := &types.BeachballOptions{} result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) - assert.Equal(t, []string{"major", "minor"}, result) + assert.Equal(t, []types.ChangeType{types.ChangeTypeMajor, types.ChangeTypeMinor}, result) } // Not possible (Go doesn't distinguish between null and unset): @@ -87,49 +87,49 @@ func TestGetDisallowedChangeTypes_ReturnsEmptyArrayForEmptyGroupDisallowedTypes( "grp1": &types.PackageGroup{ Name: "grp1", Packages: []string{"foo"}, - DisallowedChangeTypes: []string{}, + DisallowedChangeTypes: []types.ChangeType{}, }, } opts := &types.BeachballOptions{} result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) - assert.Equal(t, []string{}, result) + assert.Equal(t, []types.ChangeType{}, result) } // 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: []string{"patch"}, + DisallowedChangeTypes: []types.ChangeType{types.ChangeTypePatch}, } groups := types.PackageGroups{ "grp1": &types.PackageGroup{ Name: "grp1", Packages: []string{"bar"}, - DisallowedChangeTypes: []string{"major", "minor"}, + DisallowedChangeTypes: []types.ChangeType{types.ChangeTypeMajor, types.ChangeTypeMinor}, }, } opts := &types.BeachballOptions{} result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) - assert.Equal(t, []string{"patch"}, result) + assert.Equal(t, []types.ChangeType{types.ChangeTypePatch}, result) } // prefers disallowedChangeTypes for group over package func TestGetDisallowedChangeTypes_PrefersGroupOverPackage(t *testing.T) { infos := testutil.MakePackageInfosSimple(testRoot, "foo") infos["foo"].PackageOptions = &types.PackageOptions{ - DisallowedChangeTypes: []string{"patch"}, + DisallowedChangeTypes: []types.ChangeType{types.ChangeTypePatch}, } groups := types.PackageGroups{ "grp1": &types.PackageGroup{ Name: "grp1", Packages: []string{"foo"}, - DisallowedChangeTypes: []string{"major", "minor"}, + DisallowedChangeTypes: []types.ChangeType{types.ChangeTypeMajor, types.ChangeTypeMinor}, }, } opts := &types.BeachballOptions{} result := changefile.GetDisallowedChangeTypes("foo", infos, groups, opts) - assert.Equal(t, []string{"major", "minor"}, result) + assert.Equal(t, []types.ChangeType{types.ChangeTypeMajor, types.ChangeTypeMinor}, result) } diff --git a/go/internal/commands/change.go b/go/internal/commands/change.go index 96223701f..f46694976 100644 --- a/go/internal/commands/change.go +++ b/go/internal/commands/change.go @@ -27,9 +27,9 @@ func Change(parsed types.ParsedOptions) error { options := &parsed.Options - changeType, err := types.ParseChangeType(options.Type) - if err != nil { - return fmt.Errorf("invalid change type %q: %w", options.Type, err) + changeType := options.Type + if changeType == "" { + return fmt.Errorf("--type is required for non-interactive change") } message := options.Message @@ -41,10 +41,7 @@ func Change(parsed types.ParsedOptions) error { depChangeType := changeType if options.DependentChangeType != "" { - depChangeType, err = types.ParseChangeType(options.DependentChangeType) - if err != nil { - return fmt.Errorf("invalid dependent change type: %w", err) - } + depChangeType = options.DependentChangeType } changedPackages := result.ChangedPackages diff --git a/go/internal/options/get_options.go b/go/internal/options/get_options.go index 61264fd7c..d9cd94359 100644 --- a/go/internal/options/get_options.go +++ b/go/internal/options/get_options.go @@ -105,10 +105,14 @@ func applyRepoConfig(opts *types.BeachballOptions, cfg *RepoConfig) { opts.Commit = *cfg.Commit } if cfg.DependentChangeType != "" { - opts.DependentChangeType = cfg.DependentChangeType + opts.DependentChangeType = types.ChangeType(cfg.DependentChangeType) } if cfg.DisallowedChangeTypes != nil { - opts.DisallowedChangeTypes = cfg.DisallowedChangeTypes + 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 @@ -141,13 +145,13 @@ func applyCliOptions(opts *types.BeachballOptions, cli *types.CliOptions) { opts.Command = cli.Command } if cli.ChangeType != "" { - opts.Type = cli.ChangeType + opts.Type = types.ChangeType(cli.ChangeType) } if cli.Commit != nil { opts.Commit = *cli.Commit } if cli.DependentChangeType != "" { - opts.DependentChangeType = cli.DependentChangeType + opts.DependentChangeType = types.ChangeType(cli.DependentChangeType) } if cli.Fetch != nil { opts.Fetch = *cli.Fetch diff --git a/go/internal/types/change_info.go b/go/internal/types/change_info.go index b9c0d84c1..502a19018 100644 --- a/go/internal/types/change_info.go +++ b/go/internal/types/change_info.go @@ -6,57 +6,45 @@ import ( ) // ChangeType represents the type of version bump. -type ChangeType int +type ChangeType string const ( - ChangeTypeNone ChangeType = iota - ChangeTypePrerelease - ChangeTypePrepatch - ChangeTypePatch - ChangeTypePreminor - ChangeTypeMinor - ChangeTypePremajor - ChangeTypeMajor + 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 changeTypeStrings = map[ChangeType]string{ - ChangeTypeNone: "none", - ChangeTypePrerelease: "prerelease", - ChangeTypePrepatch: "prepatch", - ChangeTypePatch: "patch", - ChangeTypePreminor: "preminor", - ChangeTypeMinor: "minor", - ChangeTypePremajor: "premajor", - ChangeTypeMajor: "major", -} - -var stringToChangeType = map[string]ChangeType{ - "none": ChangeTypeNone, - "prerelease": ChangeTypePrerelease, - "prepatch": ChangeTypePrepatch, - "patch": ChangeTypePatch, - "preminor": ChangeTypePreminor, - "minor": ChangeTypeMinor, - "premajor": ChangeTypePremajor, - "major": ChangeTypeMajor, +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 { - if s, ok := changeTypeStrings[c]; ok { - return s - } - return "unknown" + return string(c) } +// ParseChangeType validates and returns a ChangeType from a string. func ParseChangeType(s string) (ChangeType, error) { - if ct, ok := stringToChangeType[s]; ok { + ct := ChangeType(s) + if validChangeTypes[ct] { return ct, nil } - return ChangeTypeNone, fmt.Errorf("invalid change type: %q", s) + return "", fmt.Errorf("invalid change type: %q", s) } func (c ChangeType) MarshalJSON() ([]byte, error) { - return json.Marshal(c.String()) + return json.Marshal(string(c)) } func (c *ChangeType) UnmarshalJSON(data []byte) error { @@ -74,8 +62,7 @@ func (c *ChangeType) UnmarshalJSON(data []byte) error { // IsValidChangeType checks if a string is a valid change type. func IsValidChangeType(s string) bool { - _, ok := stringToChangeType[s] - return ok + return validChangeTypes[ChangeType(s)] } // ChangeFileInfo is the info saved in each change file. diff --git a/go/internal/types/options.go b/go/internal/types/options.go index 25654ecce..40c64838a 100644 --- a/go/internal/types/options.go +++ b/go/internal/types/options.go @@ -1,17 +1,25 @@ 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 string + AuthType AuthType Branch string ChangeDir string ChangeHint string Command string Commit bool - DependentChangeType string - DisallowedChangeTypes []string + DependentChangeType ChangeType + DisallowedChangeTypes []ChangeType DisallowDeletedChangeFiles bool Fetch bool GroupChanges bool @@ -22,7 +30,7 @@ type BeachballOptions struct { Path string Scope []string Token string - Type string + Type ChangeType Verbose bool } @@ -30,7 +38,7 @@ type BeachballOptions struct { // TODO: better default path value, or require path passed? func DefaultOptions() BeachballOptions { return BeachballOptions{ - AuthType: "authtoken", + AuthType: AuthTypeAuthToken, Branch: "origin/master", ChangeDir: "change", ChangeHint: "Run 'beachball change' to create a change file", @@ -42,17 +50,17 @@ func DefaultOptions() BeachballOptions { // PackageOptions represents beachball-specific options in package.json. type PackageOptions struct { - ShouldPublish *bool `json:"shouldPublish,omitempty"` - DisallowedChangeTypes []string `json:"disallowedChangeTypes,omitempty"` - DefaultNearestBumpType string `json:"defaultNearestBumpType,omitempty"` + 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 []string `json:"disallowedChangeTypes,omitempty"` + 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. diff --git a/go/internal/types/package_info.go b/go/internal/types/package_info.go index 0aafdae3c..a35d4d89f 100644 --- a/go/internal/types/package_info.go +++ b/go/internal/types/package_info.go @@ -29,7 +29,7 @@ type ScopedPackages map[string]bool type PackageGroup struct { Name string Packages []string - DisallowedChangeTypes []string + DisallowedChangeTypes []ChangeType } // PackageGroups maps group name to group info. diff --git a/go/internal/validation/validate.go b/go/internal/validation/validate.go index 47a229a44..538a5462f 100644 --- a/go/internal/validation/validate.go +++ b/go/internal/validation/validate.go @@ -76,7 +76,7 @@ func Validate(parsed types.ParsedOptions, validateOpts ValidateOptions) (*Valida 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 != "password" { + } else if strings.HasPrefix(options.Token, "$") && options.AuthType != types.AuthTypePassword { logError(fmt.Sprintf("token appears to be a variable reference: %q", options.Token)) } } @@ -96,25 +96,25 @@ func Validate(parsed types.ParsedOptions, validateOpts ValidateOptions) (*Valida for _, entry := range changeSet { disallowed := changefile.GetDisallowedChangeTypes(entry.Change.PackageName, packageInfos, packageGroups, options) - changeTypeStr := entry.Change.Type.String() - if changeTypeStr == "" { + changeType := entry.Change.Type + if changeType == "" { logError(fmt.Sprintf("Change type is missing in %s", entry.ChangeFile)) - } else if !IsValidChangeType(changeTypeStr) { - logError(fmt.Sprintf("Invalid change type detected in %s: %q", entry.ChangeFile, changeTypeStr)) + } else if !IsValidChangeType(changeType) { + logError(fmt.Sprintf("Invalid change type detected in %s: %q", entry.ChangeFile, changeType)) } else { for _, d := range disallowed { - if changeTypeStr == d { - logError(fmt.Sprintf("Disallowed change type detected in %s: %q", entry.ChangeFile, changeTypeStr)) + if changeType == d { + logError(fmt.Sprintf("Disallowed change type detected in %s: %q", entry.ChangeFile, changeType)) break } } } - depTypeStr := entry.Change.DependentChangeType.String() - if depTypeStr == "" { + depType := entry.Change.DependentChangeType + if depType == "" { logError(fmt.Sprintf("dependentChangeType is missing in %s", entry.ChangeFile)) - } else if !IsValidDependentChangeType(depTypeStr, disallowed) { - logError(fmt.Sprintf("Invalid dependentChangeType detected in %s: %q", entry.ChangeFile, depTypeStr)) + } else if !IsValidDependentChangeType(depType, disallowed) { + logError(fmt.Sprintf("Invalid dependentChangeType detected in %s: %q", entry.ChangeFile, depType)) } } diff --git a/go/internal/validation/validators.go b/go/internal/validation/validators.go index 7fea53b3a..933e19912 100644 --- a/go/internal/validation/validators.go +++ b/go/internal/validation/validators.go @@ -1,31 +1,30 @@ package validation -import "github.com/microsoft/beachball/internal/types" +import ( + "slices" -var validAuthTypes = map[string]bool{ - "authtoken": true, - "password": true, + "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 string) bool { +func IsValidAuthType(authType types.AuthType) bool { return validAuthTypes[authType] } -// IsValidChangeType checks if a change type string is valid. -func IsValidChangeType(s string) bool { - return types.IsValidChangeType(s) +// 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(s string, disallowed []string) bool { - if !types.IsValidChangeType(s) { +func IsValidDependentChangeType(ct types.ChangeType, disallowed []types.ChangeType) bool { + if !types.IsValidChangeType(string(ct)) { return false } - for _, d := range disallowed { - if s == d { - return false - } - } - return true + return !slices.Contains(disallowed, ct) } From bb6fbe9875ae661aedab68c6519ee321143e1527 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Tue, 10 Mar 2026 17:30:43 -0700 Subject: [PATCH 22/38] go modernizer --- .../changefile/changed_packages_test.go | 8 ++++---- go/internal/changefile/write_change_files.go | 7 ++++--- go/internal/git/commands.go | 2 +- go/internal/testutil/fixtures.go | 20 +++++++++---------- go/internal/testutil/repository_factory.go | 6 +++--- go/internal/validation/validate.go | 8 +++----- 6 files changed, 25 insertions(+), 26 deletions(-) diff --git a/go/internal/changefile/changed_packages_test.go b/go/internal/changefile/changed_packages_test.go index a3ed76586..5e8f8bb30 100644 --- a/go/internal/changefile/changed_packages_test.go +++ b/go/internal/changefile/changed_packages_test.go @@ -197,25 +197,25 @@ func TestExcludesPackagesWithExistingChangeFiles(t *testing.T) { } func TestIgnoresPackageChangesAsAppropriate(t *testing.T) { - rootPkg := map[string]interface{}{ + rootPkg := map[string]any{ "name": "test-monorepo", "version": "1.0.0", "private": true, "workspaces": []string{"packages/*"}, } - packages := map[string]map[string]interface{}{ + 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]interface{}{"shouldPublish": false}, + "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]interface{}{ + groups := map[string]map[string]map[string]any{ "packages": packages, } diff --git a/go/internal/changefile/write_change_files.go b/go/internal/changefile/write_change_files.go index b8eeb8542..977557725 100644 --- a/go/internal/changefile/write_change_files.go +++ b/go/internal/changefile/write_change_files.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "regexp" + "strings" "github.com/google/uuid" "github.com/microsoft/beachball/internal/git" @@ -77,11 +78,11 @@ func WriteChangeFiles(options *types.BeachballOptions, changes []types.ChangeFil if options.Commit { action = "committed" } - fileList := "" + var fileList strings.Builder for _, f := range filePaths { - fileList += fmt.Sprintf("\n - %s", f) + fmt.Fprintf(&fileList, "\n - %s", f) } - logging.Info.Printf("git %s these change files:%s", action, fileList) + logging.Info.Printf("git %s these change files:%s", action, fileList.String()) } return nil diff --git a/go/internal/git/commands.go b/go/internal/git/commands.go index 4b707b595..ac11aa267 100644 --- a/go/internal/git/commands.go +++ b/go/internal/git/commands.go @@ -226,7 +226,7 @@ func splitLines(s string) []string { return nil } var lines []string - for _, line := range strings.Split(s, "\n") { + for line := range strings.SplitSeq(s, "\n") { if line != "" { lines = append(lines, line) } diff --git a/go/internal/testutil/fixtures.go b/go/internal/testutil/fixtures.go index a6d25e5f8..371f96bde 100644 --- a/go/internal/testutil/fixtures.go +++ b/go/internal/testutil/fixtures.go @@ -8,7 +8,7 @@ import ( // Fixture helpers for common repo types. -func writePkgJSON(dir string, pkg map[string]interface{}) { +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) @@ -16,7 +16,7 @@ func writePkgJSON(dir string, pkg map[string]interface{}) { // SetupSinglePackage sets up a single-package repo fixture. func SetupSinglePackage(dir string) { - writePkgJSON(dir, map[string]interface{}{ + writePkgJSON(dir, map[string]any{ "name": "foo", "version": "1.0.0", "dependencies": map[string]string{ @@ -28,14 +28,14 @@ func SetupSinglePackage(dir string) { // SetupMonorepo sets up a monorepo fixture with multiple packages. func SetupMonorepo(dir string) { - writePkgJSON(dir, map[string]interface{}{ + writePkgJSON(dir, map[string]any{ "name": "monorepo", "version": "1.0.0", "private": true, "workspaces": []string{"packages/*"}, }) - packages := map[string]map[string]interface{}{ + 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"}, @@ -52,37 +52,37 @@ func SetupMonorepo(dir string) { func SetupMultiProject(dir string) { // Project A projA := filepath.Join(dir, "project-a") - writePkgJSON(projA, map[string]interface{}{ + 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]interface{}{ + 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]interface{}{ + 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]interface{}{ + 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]interface{}{ + 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]interface{}, groups map[string]map[string]map[string]interface{}) { +func SetupCustomMonorepo(dir string, rootPkg map[string]any, groups map[string]map[string]map[string]any) { writePkgJSON(dir, rootPkg) for groupDir, packages := range groups { diff --git a/go/internal/testutil/repository_factory.go b/go/internal/testutil/repository_factory.go index 60861f89d..8aa0f3b9a 100644 --- a/go/internal/testutil/repository_factory.go +++ b/go/internal/testutil/repository_factory.go @@ -13,8 +13,8 @@ type RepositoryFactory struct { t *testing.T bareDir string fixtureType string - customRoot map[string]interface{} - customGroups map[string]map[string]map[string]interface{} + customRoot map[string]any + customGroups map[string]map[string]map[string]any } // NewRepositoryFactory creates a factory for the given fixture type. @@ -38,7 +38,7 @@ func NewRepositoryFactory(t *testing.T, fixtureType string) *RepositoryFactory { } // NewCustomRepositoryFactory creates a factory with custom package definitions. -func NewCustomRepositoryFactory(t *testing.T, rootPkg map[string]interface{}, groups map[string]map[string]map[string]interface{}) *RepositoryFactory { +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-*") diff --git a/go/internal/validation/validate.go b/go/internal/validation/validate.go index 538a5462f..fa9dc715f 100644 --- a/go/internal/validation/validate.go +++ b/go/internal/validation/validate.go @@ -2,6 +2,7 @@ package validation import ( "fmt" + "slices" "sort" "strings" @@ -102,11 +103,8 @@ func Validate(parsed types.ParsedOptions, validateOpts ValidateOptions) (*Valida } else if !IsValidChangeType(changeType) { logError(fmt.Sprintf("Invalid change type detected in %s: %q", entry.ChangeFile, changeType)) } else { - for _, d := range disallowed { - if changeType == d { - logError(fmt.Sprintf("Disallowed change type detected in %s: %q", entry.ChangeFile, changeType)) - break - } + if slices.Contains(disallowed, changeType) { + logError(fmt.Sprintf("Disallowed change type detected in %s: %q", entry.ChangeFile, changeType)) } } From af9cd18441d1d02b43c0790c7e65155389059e28 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Tue, 10 Mar 2026 22:21:17 -0700 Subject: [PATCH 23/38] more go modernization and fixes --- CLAUDE.md | 6 ++++-- go/internal/changefile/changed_packages.go | 7 +++---- go/internal/changefile/changed_packages_test.go | 4 ++-- go/internal/monorepo/package_groups_test.go | 14 +++++++------- go/internal/validation/validate.go | 5 +---- 5 files changed, 17 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f394cb090..7f5078d7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -219,12 +219,14 @@ Both implement: CLI parsing, JSON config loading (`.beachballrc.json` and `packa Not implemented: JS config files, interactive prompts, `bumpInMemory`, publish/bump/changelog, pnpm/rush/lerna workspaces. -### Requirements +### Implementation instructions -The behavior and tests as specified in the TypeScript code must be matched exactly in the Go/Rust code. Do not change behavior 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 implementing it. +The behavior and tests as specified in the TypeScript code must be matched exactly in the Go/Rust code. Do not change behavior 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. 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. + ### Structure - **Rust**: `src/` with nested modules (`types/`, `options/`, `git/`, `monorepo/`, `changefile/`, `validation/`, `commands/`), integration tests in `tests/` with shared helpers in `tests/common/` diff --git a/go/internal/changefile/changed_packages.go b/go/internal/changefile/changed_packages.go index bbd194ba9..cfcca2884 100644 --- a/go/internal/changefile/changed_packages.go +++ b/go/internal/changefile/changed_packages.go @@ -3,8 +3,10 @@ package changefile import ( "encoding/json" "fmt" + "maps" "os" "path/filepath" + "slices" "github.com/microsoft/beachball/internal/git" "github.com/microsoft/beachball/internal/logging" @@ -216,10 +218,7 @@ func GetChangedPackages(options *types.BeachballOptions, packageInfos types.Pack } if len(existingPackages) > 0 { - var sorted []string - for name := range existingPackages { - sorted = append(sorted, name) - } + sorted := slices.Sorted(maps.Keys(existingPackages)) logging.Info.Printf("Your local repository already has change files for these packages:\n%s", logging.BulletedList(sorted)) } diff --git a/go/internal/changefile/changed_packages_test.go b/go/internal/changefile/changed_packages_test.go index 5e8f8bb30..ecb412fe2 100644 --- a/go/internal/changefile/changed_packages_test.go +++ b/go/internal/changefile/changed_packages_test.go @@ -1,7 +1,7 @@ package changefile_test import ( - "sort" + "slices" "testing" "github.com/microsoft/beachball/internal/changefile" @@ -112,7 +112,7 @@ func TestReturnsAllPackagesWithAllTrue(t *testing.T) { opts, infos, scoped := getOptionsAndPackages(t, repo, &overrides, nil) result, err := changefile.GetChangedPackages(&opts, infos, scoped) require.NoError(t, err) - sort.Strings(result) + slices.Sort(result) assert.Equal(t, []string{"a", "b", "bar", "baz", "foo"}, result) } diff --git a/go/internal/monorepo/package_groups_test.go b/go/internal/monorepo/package_groups_test.go index f79fdeb95..b0496e8e8 100644 --- a/go/internal/monorepo/package_groups_test.go +++ b/go/internal/monorepo/package_groups_test.go @@ -1,7 +1,7 @@ package monorepo_test import ( - "sort" + "slices" "testing" "github.com/microsoft/beachball/internal/monorepo" @@ -36,7 +36,7 @@ func TestGetPackageGroups_ReturnsGroupsBasedOnSpecificFolders(t *testing.T) { assert.Len(t, result, 1) grp := result["grp1"] assert.NotNil(t, grp) - sort.Strings(grp.Packages) + slices.Sort(grp.Packages) assert.Equal(t, []string{"bar", "foo"}, grp.Packages) } @@ -55,7 +55,7 @@ func TestGetPackageGroups_HandlesSingleLevelGlobs(t *testing.T) { result := monorepo.GetPackageGroups(infos, root, groups) grp := result["ui"] assert.NotNil(t, grp) - sort.Strings(grp.Packages) + slices.Sort(grp.Packages) assert.Equal(t, []string{"ui-button", "ui-input"}, grp.Packages) } @@ -74,7 +74,7 @@ func TestGetPackageGroups_HandlesMultiLevelGlobs(t *testing.T) { result := monorepo.GetPackageGroups(infos, root, groups) grp := result["ui"] assert.NotNil(t, grp) - sort.Strings(grp.Packages) + slices.Sort(grp.Packages) assert.Equal(t, []string{"ui-button", "ui-input"}, grp.Packages) } @@ -93,7 +93,7 @@ func TestGetPackageGroups_HandlesMultipleIncludePatterns(t *testing.T) { result := monorepo.GetPackageGroups(infos, root, groups) grp := result["mixed"] assert.NotNil(t, grp) - sort.Strings(grp.Packages) + slices.Sort(grp.Packages) assert.Equal(t, []string{"bar", "foo"}, grp.Packages) } @@ -113,7 +113,7 @@ func TestGetPackageGroups_HandlesExcludePatterns(t *testing.T) { result := monorepo.GetPackageGroups(infos, root, groups) grp := result["public"] assert.NotNil(t, grp) - sort.Strings(grp.Packages) + slices.Sort(grp.Packages) assert.Equal(t, []string{"bar", "foo"}, grp.Packages) } @@ -133,7 +133,7 @@ func TestGetPackageGroups_HandlesGlobExclude(t *testing.T) { result := monorepo.GetPackageGroups(infos, root, groups) grp := result["non-core"] assert.NotNil(t, grp) - sort.Strings(grp.Packages) + slices.Sort(grp.Packages) assert.Equal(t, []string{"ui-button", "ui-input"}, grp.Packages) } diff --git a/go/internal/validation/validate.go b/go/internal/validation/validate.go index fa9dc715f..d630166eb 100644 --- a/go/internal/validation/validate.go +++ b/go/internal/validation/validate.go @@ -3,7 +3,6 @@ package validation import ( "fmt" "slices" - "sort" "strings" "github.com/microsoft/beachball/internal/changefile" @@ -142,9 +141,7 @@ func Validate(parsed types.ParsedOptions, validateOpts ValidateOptions) (*Valida } else if len(options.Package) > 0 { msg = "Considering the specific --package" } - sorted := make([]string, len(changedPackages)) - copy(sorted, changedPackages) - sort.Strings(sorted) + sorted := slices.Sorted(slices.Values(changedPackages)) logging.Info.Printf("%s:\n%s\n", msg, logging.BulletedList(sorted)) } From 2af9f9ae0407567bd5a9564b7fb3b3381e7642cd Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Tue, 10 Mar 2026 22:54:10 -0700 Subject: [PATCH 24/38] go: add verbose logging --- CLAUDE.md | 2 +- go/internal/changefile/changed_packages.go | 50 ++--- .../changefile/changed_packages_test.go | 61 +++++- go/internal/changefile/read_change_files.go | 57 +++-- .../changefile/read_change_files_test.go | 201 ++++++++++++++++++ go/internal/commands/change_test.go | 10 + go/internal/logging/logging.go | 30 ++- go/internal/monorepo/filter_ignored.go | 6 +- go/internal/monorepo/package_groups.go | 24 +++ go/internal/monorepo/package_infos.go | 9 + go/internal/monorepo/path_included_test.go | 14 +- go/internal/options/get_options.go | 5 + go/internal/testutil/capture_logging.go | 10 +- .../validation/are_change_files_deleted.go | 10 + 14 files changed, 407 insertions(+), 82 deletions(-) create mode 100644 go/internal/changefile/read_change_files_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 7f5078d7a..2dfbe49b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -221,7 +221,7 @@ Not implemented: JS config files, interactive prompts, `bumpInMemory`, publish/b ### Implementation instructions -The behavior and tests as specified in the TypeScript code must be matched exactly in the Go/Rust code. Do not change behavior 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. +The behavior, log messages, and tests as specified in the TypeScript code must be matched exactly 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. 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. diff --git a/go/internal/changefile/changed_packages.go b/go/internal/changefile/changed_packages.go index cfcca2884..ad2e5506d 100644 --- a/go/internal/changefile/changed_packages.go +++ b/go/internal/changefile/changed_packages.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "slices" + "strings" "github.com/microsoft/beachball/internal/git" "github.com/microsoft/beachball/internal/logging" @@ -55,17 +56,17 @@ func getMatchingPackage(file, cwd string, packagesByPath map[string]*types.Packa // 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 - verbose := options.Verbose if options.All { - if verbose { - logging.Info.Println("--all option was provided, so including all packages that are in scope (regardless of changes)") - } + 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, _ := isPackageIncluded(pkg, scopedPackages) + 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 @@ -93,14 +94,7 @@ func getAllChangedPackages(options *types.BeachballOptions, packageInfos types.P } changes = append(changes, staged...) - if verbose { - count := len(changes) - s := "s" - if count == 1 { - s = "" - } - logging.Info.Printf("Found %d changed file%s in current branch (before filtering)", count, s) - } + logging.Verbose.Printf("Found %s in current branch (before filtering)", logging.Count(len(changes), "changed file")) if len(changes) == 0 { return nil, nil @@ -111,12 +105,10 @@ func getAllChangedPackages(options *types.BeachballOptions, packageInfos types.P ignorePatterns = append(ignorePatterns, fmt.Sprintf("%s/*.json", options.ChangeDir)) ignorePatterns = append(ignorePatterns, "CHANGELOG.md", "CHANGELOG.json") - nonIgnored := monorepo.FilterIgnoredFiles(changes, ignorePatterns, verbose) + nonIgnored := monorepo.FilterIgnoredFiles(changes, ignorePatterns) if len(nonIgnored) == 0 { - if verbose { - logging.Info.Println("All files were ignored") - } + logging.Verbose.Println("All files were ignored") return nil, nil } @@ -139,30 +131,16 @@ func getAllChangedPackages(options *types.BeachballOptions, packageInfos types.P included, reason := isPackageIncluded(pkgInfo, scopedPackages) if !included { - if verbose { - logging.Info.Printf(" - ~~%s~~ (%s)", file, reason) - } + logging.Verbose.Printf(" - ~~%s~~ (%s)", file, reason) } else { includedPackages[pkgInfo.Name] = true fileCount++ - if verbose { - logging.Info.Printf(" - %s", file) - } + logging.Verbose.Printf(" - %s", file) } } - if verbose { - pkgCount := len(includedPackages) - fs := "s" - if fileCount == 1 { - fs = "" - } - ps := "s" - if pkgCount == 1 { - ps = "" - } - logging.Info.Printf("Found %d file%s in %d package%s that should be published", fileCount, fs, pkgCount, ps) - } + 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 { @@ -214,6 +192,8 @@ func GetChangedPackages(options *types.BeachballOptions, packageInfos types.Pack 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) } } diff --git a/go/internal/changefile/changed_packages_test.go b/go/internal/changefile/changed_packages_test.go index ecb412fe2..c006c1bf0 100644 --- a/go/internal/changefile/changed_packages_test.go +++ b/go/internal/changefile/changed_packages_test.go @@ -46,8 +46,9 @@ func checkOutTestBranch(repo *testutil.Repository, name string) { repo.Checkout("-b", name, testutil.DefaultBranch) } -// ===== Basic tests ===== +// ===== 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() @@ -58,6 +59,7 @@ func TestReturnsEmptyListWhenNoChanges(t *testing.T) { 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() @@ -74,6 +76,7 @@ func TestReturnsPackageNameWhenChangesInBranch(t *testing.T) { 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() @@ -86,6 +89,7 @@ func TestReturnsEmptyListForChangelogChanges(t *testing.T) { 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() @@ -103,6 +107,7 @@ func TestReturnsGivenPackageNamesAsIs(t *testing.T) { 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() @@ -116,8 +121,9 @@ func TestReturnsAllPackagesWithAllTrue(t *testing.T) { assert.Equal(t, []string{"a", "b", "bar", "baz", "foo"}, result) } -// ===== Single package tests ===== +// ===== 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() @@ -133,6 +139,7 @@ func TestDetectsChangedFilesInSinglePackageRepo(t *testing.T) { assert.Equal(t, []string{"foo"}, result2) } +// TS: "respects ignorePatterns option" func TestRespectsIgnorePatterns(t *testing.T) { factory := testutil.NewRepositoryFactory(t, "single") repo := factory.CloneRepository() @@ -148,15 +155,18 @@ func TestRespectsIgnorePatterns(t *testing.T) { repo.WriteFileContent("yarn.lock", "changed") repo.Git([]string{"add", "-A"}) - buf := testutil.CaptureLogging(t) + buf := testutil.CaptureVerboseLogging(t) result, err := changefile.GetChangedPackages(&opts, infos, scoped) require.NoError(t, err) assert.Empty(t, result) - assert.Contains(t, buf.String(), "ignored by pattern") + 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() @@ -172,6 +182,7 @@ func TestDetectsChangedFilesInMonorepo(t *testing.T) { 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() @@ -183,11 +194,13 @@ func TestExcludesPackagesWithExistingChangeFiles(t *testing.T) { opts, infos, scoped := getOptionsAndPackages(t, repo, &overrides, nil) testutil.GenerateChangeFiles(t, []string{"foo"}, &opts, repo) - buf := testutil.CaptureLogging(t) + buf := testutil.CaptureVerboseLogging(t) result, err := changefile.GetChangedPackages(&opts, infos, scoped) require.NoError(t, err) assert.Empty(t, result) - assert.Contains(t, buf.String(), "already has change files for these packages") + 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") @@ -196,6 +209,32 @@ func TestExcludesPackagesWithExistingChangeFiles(t *testing.T) { 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", @@ -234,15 +273,19 @@ func TestIgnoresPackageChangesAsAppropriate(t *testing.T) { overrides.IgnorePatterns = []string{"**/jest.config.js"} overrides.Verbose = true - buf := testutil.CaptureLogging(t) + 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) - assert.Contains(t, buf.String(), "is private") - assert.Contains(t, buf.String(), "is out of scope") + 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() diff --git a/go/internal/changefile/read_change_files.go b/go/internal/changefile/read_change_files.go index 4a9a846d7..d887de5eb 100644 --- a/go/internal/changefile/read_change_files.go +++ b/go/internal/changefile/read_change_files.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/microsoft/beachball/internal/git" + "github.com/microsoft/beachball/internal/logging" "github.com/microsoft/beachball/internal/types" ) @@ -38,35 +39,51 @@ func ReadChangeFiles(options *types.BeachballOptions, packageInfos types.Package 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 multi format first + // 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 { - for _, change := range multi.Changes { - if _, ok := packageInfos[change.PackageName]; ok { - if scopedPackages[change.PackageName] { - changeSet = append(changeSet, types.ChangeSetEntry{ - Change: change, - ChangeFile: file, - }) - } - } + 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 } - continue } - // Try single format - var single types.ChangeFileInfo - if err := json.Unmarshal(data, &single); err == nil && single.PackageName != "" { - if _, ok := packageInfos[single.PackageName]; ok { - if scopedPackages[single.PackageName] { - changeSet = append(changeSet, types.ChangeSetEntry{ - Change: single, - ChangeFile: file, - }) + // 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, + }) } } } 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..39b9c4506 --- /dev/null +++ b/go/internal/changefile/read_change_files_test.go @@ -0,0 +1,201 @@ +package changefile_test + +import ( + "encoding/json" + "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) + + // Write a grouped change file manually + changePath := changefile.GetChangePath(&opts) + os.MkdirAll(changePath, 0o755) + grouped := types.ChangeInfoMultiple{ + Changes: []types.ChangeFileInfo{ + {Type: types.ChangeTypeMinor, Comment: "foo change", PackageName: "foo", Email: "test@test.com", DependentChangeType: types.ChangeTypePatch}, + {Type: types.ChangeTypeMinor, Comment: "bar change", PackageName: "bar", Email: "test@test.com", DependentChangeType: types.ChangeTypePatch}, + }, + } + data, _ := json.MarshalIndent(grouped, "", " ") + os.WriteFile(filepath.Join(changePath, "change-grouped.json"), data, 0o644) + repo.Git([]string{"add", "-A"}) + repo.Git([]string{"commit", "-m", "grouped change file"}) + + 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") + + // Make bar private + barPkgPath := filepath.Join(repo.RootPath(), "packages", "bar", "package.json") + barData, _ := os.ReadFile(barPkgPath) + var barPkg map[string]any + json.Unmarshal(barData, &barPkg) + barPkg["private"] = true + barData, _ = json.MarshalIndent(barPkg, "", " ") + os.WriteFile(barPkgPath, barData, 0o644) + repo.Git([]string{"add", "-A"}) + repo.Git([]string{"commit", "-m", "make bar private"}) + + 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") + + // Make bar private + barPkgPath := filepath.Join(repo.RootPath(), "packages", "bar", "package.json") + barData, _ := os.ReadFile(barPkgPath) + var barPkg map[string]any + json.Unmarshal(barData, &barPkg) + barPkg["private"] = true + barData, _ = json.MarshalIndent(barPkg, "", " ") + os.WriteFile(barPkgPath, barData, 0o644) + repo.Git([]string{"add", "-A"}) + repo.Git([]string{"commit", "-m", "make bar private"}) + + overrides := getDefaultOptions() + overrides.GroupChanges = true + opts, infos, scoped := getReadTestOptionsAndPackages(t, repo, &overrides) + + // Write a grouped change file with invalid entries + changePath := changefile.GetChangePath(&opts) + os.MkdirAll(changePath, 0o755) + grouped := types.ChangeInfoMultiple{ + Changes: []types.ChangeFileInfo{ + {Type: types.ChangeTypeMinor, Comment: "fake change", PackageName: "fake", Email: "test@test.com", DependentChangeType: types.ChangeTypePatch}, + {Type: types.ChangeTypeMinor, Comment: "bar change", PackageName: "bar", Email: "test@test.com", DependentChangeType: types.ChangeTypePatch}, + {Type: types.ChangeTypeMinor, Comment: "foo change", PackageName: "foo", Email: "test@test.com", DependentChangeType: types.ChangeTypePatch}, + }, + } + data, _ := json.MarshalIndent(grouped, "", " ") + os.WriteFile(filepath.Join(changePath, "change-grouped.json"), data, 0o644) + repo.Git([]string{"add", "-A"}) + repo.Git([]string{"commit", "-m", "grouped change 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, "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)) +} diff --git a/go/internal/commands/change_test.go b/go/internal/commands/change_test.go index 862e1eba9..d293c58db 100644 --- a/go/internal/commands/change_test.go +++ b/go/internal/commands/change_test.go @@ -88,6 +88,7 @@ func TestCreatesAndStagesChangeFile(t *testing.T) { repo.Checkout("-b", "stages-change-test", testutil.DefaultBranch) repo.CommitChange("file.js") + buf := testutil.CaptureLogging(t) repoOpts := getDefaultOptions() repoOpts.Commit = false @@ -115,6 +116,7 @@ func TestCreatesAndStagesChangeFile(t *testing.T) { json.Unmarshal(data, &change) assert.Equal(t, "stage me please", change.Comment) assert.Equal(t, "foo", change.PackageName) + assert.Contains(t, buf.String(), "git staged these change files:") } func TestCreatesAndCommitsChangeFile(t *testing.T) { @@ -156,6 +158,7 @@ func TestCreatesAndCommitsChangeFileWithChangeDir(t *testing.T) { repo.Checkout("-b", "changedir-test", testutil.DefaultBranch) repo.CommitChange("file.js") + buf := testutil.CaptureLogging(t) repoOpts := getDefaultOptions() repoOpts.ChangeDir = "changeDir" @@ -182,6 +185,7 @@ func TestCreatesAndCommitsChangeFileWithChangeDir(t *testing.T) { var change types.ChangeFileInfo json.Unmarshal(data, &change) assert.Equal(t, "commit me please", change.Comment) + assert.Contains(t, buf.String(), "git committed these change files:") } func TestCreatesChangeFileWhenNoChangesButPackageProvided(t *testing.T) { @@ -189,6 +193,7 @@ func TestCreatesChangeFileWhenNoChangesButPackageProvided(t *testing.T) { repo := factory.CloneRepository() repo.Checkout("-b", "package-flag-test", testutil.DefaultBranch) + buf := testutil.CaptureLogging(t) repoOpts := getDefaultOptions() repoOpts.Commit = false @@ -212,6 +217,7 @@ func TestCreatesChangeFileWhenNoChangesButPackageProvided(t *testing.T) { var change types.ChangeFileInfo json.Unmarshal(data, &change) assert.Equal(t, "foo", change.PackageName) + assert.Contains(t, buf.String(), "git staged these change files:") } func TestCreatesAndCommitsChangeFilesForMultiplePackages(t *testing.T) { @@ -221,6 +227,7 @@ func TestCreatesAndCommitsChangeFilesForMultiplePackages(t *testing.T) { repo.CommitChange("packages/foo/file.js") repo.CommitChange("packages/bar/file.js") + buf := testutil.CaptureLogging(t) repoOpts := getDefaultOptions() cli := types.CliOptions{ @@ -251,6 +258,7 @@ func TestCreatesAndCommitsChangeFilesForMultiplePackages(t *testing.T) { assert.True(t, packageNames["foo"], "expected foo") assert.True(t, packageNames["bar"], "expected bar") + assert.Contains(t, buf.String(), "git committed these change files:") } func TestCreatesAndCommitsGroupedChangeFile(t *testing.T) { @@ -260,6 +268,7 @@ func TestCreatesAndCommitsGroupedChangeFile(t *testing.T) { repo.CommitChange("packages/foo/file.js") repo.CommitChange("packages/bar/file.js") + buf := testutil.CaptureLogging(t) repoOpts := getDefaultOptions() repoOpts.GroupChanges = true @@ -294,4 +303,5 @@ func TestCreatesAndCommitsGroupedChangeFile(t *testing.T) { 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/logging/logging.go b/go/internal/logging/logging.go index 7e845af42..e0d11737b 100644 --- a/go/internal/logging/logging.go +++ b/go/internal/logging/logging.go @@ -9,23 +9,43 @@ import ( ) var ( - Info = log.New(os.Stdout, "", 0) - Warn = log.New(os.Stderr, "WARN: ", 0) - Error = log.New(os.Stderr, "ERROR: ", 0) + 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). -func SetOutput(w io.Writer) { +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. +// 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. diff --git a/go/internal/monorepo/filter_ignored.go b/go/internal/monorepo/filter_ignored.go index acf7a0e04..8c3dc5098 100644 --- a/go/internal/monorepo/filter_ignored.go +++ b/go/internal/monorepo/filter_ignored.go @@ -9,7 +9,7 @@ import ( // 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, verbose bool) []string { +func FilterIgnoredFiles(files []string, patterns []string) []string { var result []string for _, file := range files { @@ -26,9 +26,7 @@ func FilterIgnoredFiles(files []string, patterns []string, verbose bool) []strin } if ignored { - if verbose { - logging.Info.Printf(" - ~~%s~~ (ignored by pattern %q)", file, matchedPattern) - } + logging.Verbose.Printf(" - ~~%s~~ (ignored by pattern %q)", file, matchedPattern) } else { result = append(result, file) } diff --git a/go/internal/monorepo/package_groups.go b/go/internal/monorepo/package_groups.go index 8fc9bc950..0b7aafbcc 100644 --- a/go/internal/monorepo/package_groups.go +++ b/go/internal/monorepo/package_groups.go @@ -1,9 +1,12 @@ package monorepo import ( + "fmt" "path/filepath" + "slices" "github.com/bmatcuk/doublestar/v4" + "github.com/microsoft/beachball/internal/logging" "github.com/microsoft/beachball/internal/types" ) @@ -54,5 +57,26 @@ func GetPackageGroups(packageInfos types.PackageInfos, rootPath string, groups [ 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_infos.go b/go/internal/monorepo/package_infos.go index ad69b69cd..c56b59c05 100644 --- a/go/internal/monorepo/package_infos.go +++ b/go/internal/monorepo/package_infos.go @@ -2,11 +2,13 @@ package monorepo import ( "encoding/json" + "fmt" "os" "path/filepath" "strings" "github.com/bmatcuk/doublestar/v4" + "github.com/microsoft/beachball/internal/logging" "github.com/microsoft/beachball/internal/types" ) @@ -60,6 +62,13 @@ func GetPackageInfos(options *types.BeachballOptions) (types.PackageInfos, error } absMatch, _ := filepath.Abs(match) 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 nil, fmt.Errorf("duplicate package name: %s", info.Name) + } infos[info.Name] = info } } diff --git a/go/internal/monorepo/path_included_test.go b/go/internal/monorepo/path_included_test.go index 4f571cd74..d372ff7c3 100644 --- a/go/internal/monorepo/path_included_test.go +++ b/go/internal/monorepo/path_included_test.go @@ -8,40 +8,40 @@ import ( ) func TestFilterIgnoredFiles_MatchesBasenamePatterns(t *testing.T) { - result := monorepo.FilterIgnoredFiles([]string{"src/foo.test.js"}, []string{"*.test.js"}, false) + 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/**"}, false) + 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"}, false) + 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"}, false) + 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"}, false) + 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, false) + 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, false) + result := monorepo.FilterIgnoredFiles(files, patterns) assert.Equal(t, []string{"src/index.js", "lib/utils.js"}, result) } diff --git a/go/internal/options/get_options.go b/go/internal/options/get_options.go index d9cd94359..5c9a9048d 100644 --- a/go/internal/options/get_options.go +++ b/go/internal/options/get_options.go @@ -4,6 +4,7 @@ import ( "path/filepath" "github.com/microsoft/beachball/internal/git" + "github.com/microsoft/beachball/internal/logging" "github.com/microsoft/beachball/internal/types" ) @@ -36,6 +37,10 @@ func GetParsedOptions(cwd string, cli types.CliOptions) (types.ParsedOptions, er // Apply CLI overrides applyCliOptions(&opts, &cli) + if opts.Verbose { + logging.EnableVerbose() + } + return types.ParsedOptions{Options: opts, CliOptions: cli}, nil } diff --git a/go/internal/testutil/capture_logging.go b/go/internal/testutil/capture_logging.go index 00c37786e..0f722fda4 100644 --- a/go/internal/testutil/capture_logging.go +++ b/go/internal/testutil/capture_logging.go @@ -11,7 +11,15 @@ import ( func CaptureLogging(t *testing.T) *bytes.Buffer { t.Helper() var buf bytes.Buffer - logging.SetOutput(&buf) + 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/validation/are_change_files_deleted.go b/go/internal/validation/are_change_files_deleted.go index 36a5f2045..72077b046 100644 --- a/go/internal/validation/are_change_files_deleted.go +++ b/go/internal/validation/are_change_files_deleted.go @@ -3,15 +3,25 @@ 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 } From f5952305f608ab56f28746aab66850397cfe6786 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Tue, 10 Mar 2026 23:13:55 -0700 Subject: [PATCH 25/38] go: missing tests and comments --- CLAUDE.md | 2 + .../disallowed_change_types_test.go | 16 ++--- .../changefile/read_change_files_test.go | 30 ++++++++++ .../changefile/write_change_files_test.go | 58 +++++++++++++++++++ go/internal/commands/change_test.go | 8 +++ go/internal/monorepo/package_groups_test.go | 31 ++++++++++ go/internal/monorepo/path_included_test.go | 4 ++ .../are_change_files_deleted_test.go | 3 + go/internal/validation/validate_test.go | 3 + 9 files changed, 147 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2dfbe49b4..447138d91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -227,6 +227,8 @@ When porting tests, add a comment by each Rust/Go test with the name of the corr 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. +When trying to understand existing code, attempt to use the LSP rather than grep if relevant. + ### Structure - **Rust**: `src/` with nested modules (`types/`, `options/`, `git/`, `monorepo/`, `changefile/`, `validation/`, `commands/`), integration tests in `tests/` with shared helpers in `tests/common/` diff --git a/go/internal/changefile/disallowed_change_types_test.go b/go/internal/changefile/disallowed_change_types_test.go index e3f6ff612..cf6fdca20 100644 --- a/go/internal/changefile/disallowed_change_types_test.go +++ b/go/internal/changefile/disallowed_change_types_test.go @@ -11,7 +11,7 @@ import ( var testRoot = testutil.FakeRoot() -// returns null for unknown package +// TS: "returns null for unknown package" func TestGetDisallowedChangeTypes_ReturnsNilForUnknownPackage(t *testing.T) { infos := types.PackageInfos{} groups := types.PackageGroups{} @@ -21,7 +21,7 @@ func TestGetDisallowedChangeTypes_ReturnsNilForUnknownPackage(t *testing.T) { assert.Nil(t, result) } -// falls back to main option for package without disallowedChangeTypes +// TS: "falls back to main option for package without disallowedChangeTypes" func TestGetDisallowedChangeTypes_FallsBackToMainOption(t *testing.T) { infos := testutil.MakePackageInfosSimple(testRoot, "foo") groups := types.PackageGroups{} @@ -32,7 +32,7 @@ func TestGetDisallowedChangeTypes_FallsBackToMainOption(t *testing.T) { assert.Equal(t, []types.ChangeType{types.ChangeTypeMajor}, result) } -// returns disallowedChangeTypes for package +// TS: "returns disallowedChangeTypes for package" func TestGetDisallowedChangeTypes_ReturnsPackageLevelDisallowedTypes(t *testing.T) { infos := testutil.MakePackageInfosSimple(testRoot, "foo") infos["foo"].PackageOptions = &types.PackageOptions{ @@ -48,7 +48,7 @@ func TestGetDisallowedChangeTypes_ReturnsPackageLevelDisallowedTypes(t *testing. // Not possible (Go doesn't distinguish between null and unset): // returns null if package disallowedChangeTypes is set to null -// returns empty array if package disallowedChangeTypes is set to empty array +// 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{ @@ -61,7 +61,7 @@ func TestGetDisallowedChangeTypes_ReturnsEmptyArrayForEmptyPackageDisallowedType assert.Equal(t, []types.ChangeType{}, result) } -// returns disallowedChangeTypes for package group +// TS: "returns disallowedChangeTypes for package group" func TestGetDisallowedChangeTypes_ReturnsGroupLevelDisallowedTypes(t *testing.T) { infos := testutil.MakePackageInfosSimple(testRoot, "foo") groups := types.PackageGroups{ @@ -80,7 +80,7 @@ func TestGetDisallowedChangeTypes_ReturnsGroupLevelDisallowedTypes(t *testing.T) // Not possible (Go doesn't distinguish between null and unset): // returns null if package group disallowedChangeTypes is set to null -// returns empty array if package group disallowedChangeTypes is set to empty array +// 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{ @@ -96,7 +96,7 @@ func TestGetDisallowedChangeTypes_ReturnsEmptyArrayForEmptyGroupDisallowedTypes( assert.Equal(t, []types.ChangeType{}, result) } -// returns disallowedChangeTypes for package if not in a group +// 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{ @@ -115,7 +115,7 @@ func TestGetDisallowedChangeTypes_ReturnsPackageDisallowedTypesIfNotInGroup(t *t assert.Equal(t, []types.ChangeType{types.ChangeTypePatch}, result) } -// prefers disallowedChangeTypes for group over package +// TS: "prefers disallowedChangeTypes for group over package" func TestGetDisallowedChangeTypes_PrefersGroupOverPackage(t *testing.T) { infos := testutil.MakePackageInfosSimple(testRoot, "foo") infos["foo"].PackageOptions = &types.PackageOptions{ diff --git a/go/internal/changefile/read_change_files_test.go b/go/internal/changefile/read_change_files_test.go index 39b9c4506..2a862ea09 100644 --- a/go/internal/changefile/read_change_files_test.go +++ b/go/internal/changefile/read_change_files_test.go @@ -199,3 +199,33 @@ func TestReadChangeFiles_ExcludesOutOfScopeChangeFiles(t *testing.T) { 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) + + // Write a grouped change file with bar+foo + changePath := changefile.GetChangePath(&opts) + os.MkdirAll(changePath, 0o755) + grouped := types.ChangeInfoMultiple{ + Changes: []types.ChangeFileInfo{ + {Type: types.ChangeTypeMinor, Comment: "bar change", PackageName: "bar", Email: "test@test.com", DependentChangeType: types.ChangeTypePatch}, + {Type: types.ChangeTypeMinor, Comment: "foo change", PackageName: "foo", Email: "test@test.com", DependentChangeType: types.ChangeTypePatch}, + }, + } + data, _ := json.MarshalIndent(grouped, "", " ") + os.WriteFile(filepath.Join(changePath, "change-grouped.json"), data, 0o644) + repo.Git([]string{"add", "-A"}) + repo.Git([]string{"commit", "-m", "grouped change file"}) + + changeSet := changefile.ReadChangeFiles(&opts, infos, scoped) + assert.Equal(t, []string{"foo"}, getPackageNames(changeSet)) +} diff --git a/go/internal/changefile/write_change_files_test.go b/go/internal/changefile/write_change_files_test.go index 46341b931..b83c8794b 100644 --- a/go/internal/changefile/write_change_files_test.go +++ b/go/internal/changefile/write_change_files_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/require" ) +// TS: "writes individual change files" func TestWriteChangeFiles_WritesIndividualChangeFiles(t *testing.T) { factory := testutil.NewRepositoryFactory(t, "monorepo") repo := factory.CloneRepository() @@ -75,6 +76,7 @@ func TestWriteChangeFiles_WritesIndividualChangeFiles(t *testing.T) { 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() @@ -117,6 +119,7 @@ func TestWriteChangeFiles_RespectsChangeDirOption(t *testing.T) { 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() @@ -152,3 +155,58 @@ func TestWriteChangeFiles_RespectsCommitFalse(t *testing.T) { 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{ + { + Type: types.ChangeTypePatch, + Comment: "fix foo", + PackageName: "foo", + Email: "test@test.com", + DependentChangeType: types.ChangeTypePatch, + }, + { + Type: types.ChangeTypeMinor, + Comment: "add bar feature", + PackageName: "bar", + Email: "test@test.com", + DependentChangeType: types.ChangeTypePatch, + }, + } + + 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 + data, err := os.ReadFile(files[0]) + require.NoError(t, err) + var grouped types.ChangeInfoMultiple + require.NoError(t, json.Unmarshal(data, &grouped)) + + 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_test.go b/go/internal/commands/change_test.go index d293c58db..5915d45b2 100644 --- a/go/internal/commands/change_test.go +++ b/go/internal/commands/change_test.go @@ -23,6 +23,7 @@ func getDefaultOptions() types.BeachballOptions { 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() @@ -46,6 +47,7 @@ func TestDoesNotCreateChangeFilesWhenNoChanges(t *testing.T) { 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() @@ -82,6 +84,7 @@ func TestCreatesChangeFileWithTypeAndMessage(t *testing.T) { 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() @@ -119,6 +122,7 @@ func TestCreatesAndStagesChangeFile(t *testing.T) { 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() @@ -152,6 +156,7 @@ func TestCreatesAndCommitsChangeFile(t *testing.T) { 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() @@ -188,6 +193,7 @@ func TestCreatesAndCommitsChangeFileWithChangeDir(t *testing.T) { 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() @@ -220,6 +226,7 @@ func TestCreatesChangeFileWhenNoChangesButPackageProvided(t *testing.T) { 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() @@ -261,6 +268,7 @@ func TestCreatesAndCommitsChangeFilesForMultiplePackages(t *testing.T) { 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() diff --git a/go/internal/monorepo/package_groups_test.go b/go/internal/monorepo/package_groups_test.go index b0496e8e8..54fb3e87f 100644 --- a/go/internal/monorepo/package_groups_test.go +++ b/go/internal/monorepo/package_groups_test.go @@ -12,6 +12,7 @@ import ( 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", @@ -20,6 +21,7 @@ func TestGetPackageGroups_ReturnsEmptyIfNoGroups(t *testing.T) { 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", @@ -40,6 +42,7 @@ func TestGetPackageGroups_ReturnsGroupsBasedOnSpecificFolders(t *testing.T) { 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", @@ -59,6 +62,7 @@ func TestGetPackageGroups_HandlesSingleLevelGlobs(t *testing.T) { 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", @@ -78,6 +82,7 @@ func TestGetPackageGroups_HandlesMultiLevelGlobs(t *testing.T) { 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", @@ -97,6 +102,7 @@ func TestGetPackageGroups_HandlesMultipleIncludePatterns(t *testing.T) { 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", @@ -117,6 +123,7 @@ func TestGetPackageGroups_HandlesExcludePatterns(t *testing.T) { 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", @@ -137,6 +144,30 @@ func TestGetPackageGroups_HandlesGlobExclude(t *testing.T) { 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", diff --git a/go/internal/monorepo/path_included_test.go b/go/internal/monorepo/path_included_test.go index d372ff7c3..033fe86d2 100644 --- a/go/internal/monorepo/path_included_test.go +++ b/go/internal/monorepo/path_included_test.go @@ -1,5 +1,9 @@ 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" diff --git a/go/internal/validation/are_change_files_deleted_test.go b/go/internal/validation/are_change_files_deleted_test.go index 3d3c250e7..698e56dc2 100644 --- a/go/internal/validation/are_change_files_deleted_test.go +++ b/go/internal/validation/are_change_files_deleted_test.go @@ -21,6 +21,7 @@ func getDefaultOptionsWithPath(rootPath string) types.BeachballOptions { 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() @@ -38,6 +39,7 @@ func TestAreChangeFilesDeleted_FalseWhenNoChangeFilesDeleted(t *testing.T) { 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() @@ -62,6 +64,7 @@ func TestAreChangeFilesDeleted_TrueWhenChangeFilesDeleted(t *testing.T) { 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() diff --git a/go/internal/validation/validate_test.go b/go/internal/validation/validate_test.go index 97739bd95..ef12e4faa 100644 --- a/go/internal/validation/validate_test.go +++ b/go/internal/validation/validate_test.go @@ -20,6 +20,7 @@ func getDefaultOptions() types.BeachballOptions { return defaultOptions } +// TS: "succeeds with no changes" func TestSucceedsWithNoChanges(t *testing.T) { factory := testutil.NewRepositoryFactory(t, "monorepo") repo := factory.CloneRepository() @@ -37,6 +38,7 @@ func TestSucceedsWithNoChanges(t *testing.T) { 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() @@ -55,6 +57,7 @@ func TestExitsWithErrorIfChangeFilesNeeded(t *testing.T) { 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() From 854b520d0ff59d8c51ef29e67344f6ee897b3354 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Tue, 10 Mar 2026 23:37:57 -0700 Subject: [PATCH 26/38] go: remove product code duplication in tests --- CLAUDE.md | 2 +- .../changefile/read_change_files_test.go | 68 ++--------------- go/internal/changefile/testmain_test.go | 18 +++++ .../changefile/write_change_files_test.go | 48 ++---------- go/internal/testutil/change_files.go | 73 ++++++++++++------- go/internal/testutil/repository.go | 23 ++++++ go/internal/validation/testmain_test.go | 18 +++++ 7 files changed, 116 insertions(+), 134 deletions(-) create mode 100644 go/internal/changefile/testmain_test.go create mode 100644 go/internal/validation/testmain_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 447138d91..9a3cc4d84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -227,7 +227,7 @@ When porting tests, add a comment by each Rust/Go test with the name of the corr 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. -When trying to understand existing code, attempt to use the LSP rather than grep if relevant. +Where possible, attempt to use the LSP for the language instead of grep to understand the code. Also use the LSP to check for errors after making changes. ### Structure diff --git a/go/internal/changefile/read_change_files_test.go b/go/internal/changefile/read_change_files_test.go index 2a862ea09..1cc988eea 100644 --- a/go/internal/changefile/read_change_files_test.go +++ b/go/internal/changefile/read_change_files_test.go @@ -1,7 +1,6 @@ package changefile_test import ( - "encoding/json" "os" "path/filepath" "slices" @@ -80,19 +79,7 @@ func TestReadChangeFiles_ReadsGroupedChangeFile(t *testing.T) { overrides.GroupChanges = true opts, infos, scoped := getReadTestOptionsAndPackages(t, repo, &overrides) - // Write a grouped change file manually - changePath := changefile.GetChangePath(&opts) - os.MkdirAll(changePath, 0o755) - grouped := types.ChangeInfoMultiple{ - Changes: []types.ChangeFileInfo{ - {Type: types.ChangeTypeMinor, Comment: "foo change", PackageName: "foo", Email: "test@test.com", DependentChangeType: types.ChangeTypePatch}, - {Type: types.ChangeTypeMinor, Comment: "bar change", PackageName: "bar", Email: "test@test.com", DependentChangeType: types.ChangeTypePatch}, - }, - } - data, _ := json.MarshalIndent(grouped, "", " ") - os.WriteFile(filepath.Join(changePath, "change-grouped.json"), data, 0o644) - repo.Git([]string{"add", "-A"}) - repo.Git([]string{"commit", "-m", "grouped change file"}) + testutil.GenerateChangeFiles(t, []string{"foo", "bar"}, &opts, repo) changeSet := changefile.ReadChangeFiles(&opts, infos, scoped) assert.Equal(t, []string{"bar", "foo"}, getPackageNames(changeSet)) @@ -104,17 +91,7 @@ func TestReadChangeFiles_ExcludesInvalidChangeFiles(t *testing.T) { repo := factory.CloneRepository() repo.Checkout("-b", "test", testutil.DefaultBranch) repo.CommitChange("packages/foo/file.js") - - // Make bar private - barPkgPath := filepath.Join(repo.RootPath(), "packages", "bar", "package.json") - barData, _ := os.ReadFile(barPkgPath) - var barPkg map[string]any - json.Unmarshal(barData, &barPkg) - barPkg["private"] = true - barData, _ = json.MarshalIndent(barPkg, "", " ") - os.WriteFile(barPkgPath, barData, 0o644) - repo.Git([]string{"add", "-A"}) - repo.Git([]string{"commit", "-m", "make bar private"}) + repo.UpdatePackageJson("packages/bar/package.json", map[string]any{"private": true}) opts, infos, scoped := getReadTestOptionsAndPackages(t, repo, nil) @@ -143,36 +120,13 @@ func TestReadChangeFiles_ExcludesInvalidChangesFromGroupedFile(t *testing.T) { repo := factory.CloneRepository() repo.Checkout("-b", "test", testutil.DefaultBranch) repo.CommitChange("packages/foo/file.js") - - // Make bar private - barPkgPath := filepath.Join(repo.RootPath(), "packages", "bar", "package.json") - barData, _ := os.ReadFile(barPkgPath) - var barPkg map[string]any - json.Unmarshal(barData, &barPkg) - barPkg["private"] = true - barData, _ = json.MarshalIndent(barPkg, "", " ") - os.WriteFile(barPkgPath, barData, 0o644) - repo.Git([]string{"add", "-A"}) - repo.Git([]string{"commit", "-m", "make bar private"}) + repo.UpdatePackageJson("packages/bar/package.json", map[string]any{"private": true}) overrides := getDefaultOptions() overrides.GroupChanges = true opts, infos, scoped := getReadTestOptionsAndPackages(t, repo, &overrides) - // Write a grouped change file with invalid entries - changePath := changefile.GetChangePath(&opts) - os.MkdirAll(changePath, 0o755) - grouped := types.ChangeInfoMultiple{ - Changes: []types.ChangeFileInfo{ - {Type: types.ChangeTypeMinor, Comment: "fake change", PackageName: "fake", Email: "test@test.com", DependentChangeType: types.ChangeTypePatch}, - {Type: types.ChangeTypeMinor, Comment: "bar change", PackageName: "bar", Email: "test@test.com", DependentChangeType: types.ChangeTypePatch}, - {Type: types.ChangeTypeMinor, Comment: "foo change", PackageName: "foo", Email: "test@test.com", DependentChangeType: types.ChangeTypePatch}, - }, - } - data, _ := json.MarshalIndent(grouped, "", " ") - os.WriteFile(filepath.Join(changePath, "change-grouped.json"), data, 0o644) - repo.Git([]string{"add", "-A"}) - repo.Git([]string{"commit", "-m", "grouped change file"}) + testutil.GenerateChangeFiles(t, []string{"fake", "bar", "foo"}, &opts, repo) buf := testutil.CaptureLogging(t) changeSet := changefile.ReadChangeFiles(&opts, infos, scoped) @@ -212,19 +166,7 @@ func TestReadChangeFiles_ExcludesOutOfScopeChangesFromGroupedFile(t *testing.T) overrides.GroupChanges = true opts, infos, scoped := getReadTestOptionsAndPackages(t, repo, &overrides) - // Write a grouped change file with bar+foo - changePath := changefile.GetChangePath(&opts) - os.MkdirAll(changePath, 0o755) - grouped := types.ChangeInfoMultiple{ - Changes: []types.ChangeFileInfo{ - {Type: types.ChangeTypeMinor, Comment: "bar change", PackageName: "bar", Email: "test@test.com", DependentChangeType: types.ChangeTypePatch}, - {Type: types.ChangeTypeMinor, Comment: "foo change", PackageName: "foo", Email: "test@test.com", DependentChangeType: types.ChangeTypePatch}, - }, - } - data, _ := json.MarshalIndent(grouped, "", " ") - os.WriteFile(filepath.Join(changePath, "change-grouped.json"), data, 0o644) - repo.Git([]string{"add", "-A"}) - repo.Git([]string{"commit", "-m", "grouped change file"}) + 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_test.go b/go/internal/changefile/write_change_files_test.go index b83c8794b..4cc04d906 100644 --- a/go/internal/changefile/write_change_files_test.go +++ b/go/internal/changefile/write_change_files_test.go @@ -25,20 +25,8 @@ func TestWriteChangeFiles_WritesIndividualChangeFiles(t *testing.T) { opts.Fetch = false changes := []types.ChangeFileInfo{ - { - Type: types.ChangeTypePatch, - Comment: "fix foo", - PackageName: "foo", - Email: "test@test.com", - DependentChangeType: types.ChangeTypePatch, - }, - { - Type: types.ChangeTypeMinor, - Comment: "add bar feature", - PackageName: "bar", - Email: "test@test.com", - DependentChangeType: types.ChangeTypePatch, - }, + testutil.GetChange("foo", "fix foo", types.ChangeTypePatch), + testutil.GetChange("bar", "add bar feature", types.ChangeTypeMinor), } err := changefile.WriteChangeFiles(&opts, changes) @@ -89,13 +77,7 @@ func TestWriteChangeFiles_RespectsChangeDirOption(t *testing.T) { opts.ChangeDir = "my-changes" changes := []types.ChangeFileInfo{ - { - Type: types.ChangeTypePatch, - Comment: "test change", - PackageName: "foo", - Email: "test@test.com", - DependentChangeType: types.ChangeTypePatch, - }, + testutil.GetChange("foo", "test change", types.ChangeTypePatch), } err := changefile.WriteChangeFiles(&opts, changes) @@ -135,13 +117,7 @@ func TestWriteChangeFiles_RespectsCommitFalse(t *testing.T) { opts.Commit = false changes := []types.ChangeFileInfo{ - { - Type: types.ChangeTypePatch, - Comment: "uncommitted change", - PackageName: "foo", - Email: "test@test.com", - DependentChangeType: types.ChangeTypePatch, - }, + testutil.GetChange("foo", "uncommitted change", types.ChangeTypePatch), } err := changefile.WriteChangeFiles(&opts, changes) @@ -169,20 +145,8 @@ func TestWriteChangeFiles_WritesGroupedChangeFiles(t *testing.T) { opts.GroupChanges = true changes := []types.ChangeFileInfo{ - { - Type: types.ChangeTypePatch, - Comment: "fix foo", - PackageName: "foo", - Email: "test@test.com", - DependentChangeType: types.ChangeTypePatch, - }, - { - Type: types.ChangeTypeMinor, - Comment: "add bar feature", - PackageName: "bar", - Email: "test@test.com", - DependentChangeType: types.ChangeTypePatch, - }, + testutil.GetChange("foo", "fix foo", types.ChangeTypePatch), + testutil.GetChange("bar", "add bar feature", types.ChangeTypeMinor), } err := changefile.WriteChangeFiles(&opts, changes) diff --git a/go/internal/testutil/change_files.go b/go/internal/testutil/change_files.go index 44703751f..a507feaaf 100644 --- a/go/internal/testutil/change_files.go +++ b/go/internal/testutil/change_files.go @@ -1,48 +1,65 @@ package testutil import ( - "encoding/json" "os" "path/filepath" - "regexp" "testing" - "github.com/google/uuid" "github.com/microsoft/beachball/internal/types" ) -var nonAlphanumRe = regexp.MustCompile(`[^a-zA-Z0-9@]`) +const FakeEmail = "test@test.com" -// GenerateChangeFiles creates change files for the given packages and commits them. -func GenerateChangeFiles(t *testing.T, packages []string, options *types.BeachballOptions, repo *Repository) { - t.Helper() +// 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 - changePath := filepath.Join(options.Path, options.ChangeDir) - os.MkdirAll(changePath, 0o755) +// 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, + } +} - for _, pkg := range packages { - id := uuid.New().String() - sanitized := nonAlphanumRe.ReplaceAllString(pkg, "-") - filename := sanitized + "-" + id + ".json" - filePath := filepath.Join(changePath, filename) +// 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 +} - change := types.ChangeFileInfo{ - Type: types.ChangeTypePatch, - Comment: "test change", - PackageName: pkg, - Email: "test@test.com", - DependentChangeType: types.ChangeTypePatch, - } +// 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() - data, _ := json.MarshalIndent(change, "", " ") - if err := os.WriteFile(filePath, data, 0o644); err != nil { - t.Fatalf("failed to write change file: %v", err) - } + 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) } - repo.Git([]string{"add", "-A"}) - if options.Commit { - repo.Git([]string{"commit", "-m", "Change files"}) + if err := WriteChangeFilesFn(options, changes); err != nil { + t.Fatalf("failed to write change files: %v", err) } } diff --git a/go/internal/testutil/repository.go b/go/internal/testutil/repository.go index 265e362b6..32ab9751d 100644 --- a/go/internal/testutil/repository.go +++ b/go/internal/testutil/repository.go @@ -1,6 +1,7 @@ package testutil import ( + "encoding/json" "os" "path/filepath" "testing" @@ -84,3 +85,25 @@ func (r *Repository) Push() { 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/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()) +} From 2533af97f5a43f967db71addbede297cb3dd2ab6 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Tue, 10 Mar 2026 23:58:46 -0700 Subject: [PATCH 27/38] go: simplify test options --- go/internal/options/get_options.go | 51 ++++------------------------- go/internal/options/repo_options.go | 1 + go/internal/types/options.go | 1 - 3 files changed, 7 insertions(+), 46 deletions(-) diff --git a/go/internal/options/get_options.go b/go/internal/options/get_options.go index 5c9a9048d..24affcd5a 100644 --- a/go/internal/options/get_options.go +++ b/go/internal/options/get_options.go @@ -45,51 +45,9 @@ func GetParsedOptions(cwd string, cli types.CliOptions) (types.ParsedOptions, er } // GetParsedOptionsForTest creates parsed options for testing with explicit overrides. -func GetParsedOptionsForTest(cwd string, cli types.CliOptions, repoOpts types.BeachballOptions) types.ParsedOptions { - opts := types.DefaultOptions() - opts.Path = cwd - - // Apply repo overrides - if repoOpts.Branch != "" { - opts.Branch = repoOpts.Branch - } - if !repoOpts.Fetch { - opts.Fetch = false - } - if repoOpts.All { - opts.All = true - } - if repoOpts.Verbose { - opts.Verbose = true - } - if repoOpts.Commit { - opts.Commit = repoOpts.Commit - } - if !repoOpts.Commit { - opts.Commit = false - } - if repoOpts.ChangeDir != "" { - opts.ChangeDir = repoOpts.ChangeDir - } - if repoOpts.IgnorePatterns != nil { - opts.IgnorePatterns = repoOpts.IgnorePatterns - } - if repoOpts.Scope != nil { - opts.Scope = repoOpts.Scope - } - if repoOpts.Path != "" { - opts.Path = repoOpts.Path - } - if repoOpts.DisallowDeletedChangeFiles { - opts.DisallowDeletedChangeFiles = true - } - if repoOpts.Groups != nil { - opts.Groups = repoOpts.Groups - } - if repoOpts.GroupChanges { - opts.GroupChanges = true - } - +// opts is the repo options as if from the beachball config file. +// 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 { // Apply CLI overrides applyCliOptions(&opts, &cli) @@ -97,6 +55,9 @@ func GetParsedOptionsForTest(cwd string, cli types.CliOptions, repoOpts types.Be } func applyRepoConfig(opts *types.BeachballOptions, cfg *RepoConfig) { + if cfg.AuthType != "" { + opts.AuthType = types.AuthType(cfg.AuthType) + } if cfg.Branch != "" { opts.Branch = cfg.Branch } diff --git a/go/internal/options/repo_options.go b/go/internal/options/repo_options.go index 480e97c1b..14a184c6c 100644 --- a/go/internal/options/repo_options.go +++ b/go/internal/options/repo_options.go @@ -11,6 +11,7 @@ import ( // 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"` diff --git a/go/internal/types/options.go b/go/internal/types/options.go index 40c64838a..cbdcd1d9d 100644 --- a/go/internal/types/options.go +++ b/go/internal/types/options.go @@ -35,7 +35,6 @@ type BeachballOptions struct { } // DefaultOptions returns BeachballOptions with sensible defaults. -// TODO: better default path value, or require path passed? func DefaultOptions() BeachballOptions { return BeachballOptions{ AuthType: AuthTypeAuthToken, From e114a46bafc80cbaf249bdd6377aa485adba9b4d Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Wed, 11 Mar 2026 13:55:20 -0700 Subject: [PATCH 28/38] fix go tests --- go/internal/options/get_options.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/go/internal/options/get_options.go b/go/internal/options/get_options.go index 24affcd5a..30cec4627 100644 --- a/go/internal/options/get_options.go +++ b/go/internal/options/get_options.go @@ -46,8 +46,13 @@ func GetParsedOptions(cwd string, cli types.CliOptions) (types.ParsedOptions, er // 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) From f4031984f37e5396b897da4f4ed9050caa5e1109 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Wed, 11 Mar 2026 14:10:14 -0700 Subject: [PATCH 29/38] go: better findProjectRoot (still not quite matching workspace-tools) --- go/internal/git/commands.go | 47 +++--- go/internal/git/helpers.go | 27 ---- go/internal/monorepo/package_infos.go | 108 ++++++++----- go/internal/monorepo/workspace_manager.go | 146 ++++++++++++++++++ .../monorepo/workspace_manager_test.go | 116 ++++++++++++++ 5 files changed, 357 insertions(+), 87 deletions(-) delete mode 100644 go/internal/git/helpers.go create mode 100644 go/internal/monorepo/workspace_manager.go create mode 100644 go/internal/monorepo/workspace_manager_test.go diff --git a/go/internal/git/commands.go b/go/internal/git/commands.go index ac11aa267..d4ca83f0c 100644 --- a/go/internal/git/commands.go +++ b/go/internal/git/commands.go @@ -2,6 +2,7 @@ package git import ( "fmt" + "os" "os/exec" "path/filepath" "strings" @@ -60,39 +61,49 @@ func FindGitRoot(cwd string) (string, error) { return gitStdout([]string{"rev-parse", "--show-toplevel"}, cwd) } -// FindProjectRoot walks up from cwd looking for a package.json with workspaces. -// Falls back to git root. -func FindProjectRoot(cwd string) (string, error) { - gitRoot, err := FindGitRoot(cwd) - if err != nil { - return "", err - } +// 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 gitRoot, nil + return "" } - gitRootAbs, _ := filepath.Abs(gitRoot) + root := filepath.VolumeName(absPath) + string(filepath.Separator) dir := absPath - for { - pkgJSON := filepath.Join(dir, "package.json") - if data, err := readFileIfExists(pkgJSON); err == nil && data != nil { - if hasWorkspaces(data) { - return dir, nil + for dir != root { + for _, f := range files { + candidate := filepath.Join(dir, f) + if _, err := os.Stat(candidate); err == nil { + return candidate } } - if dir == gitRootAbs { - break - } parent := filepath.Dir(dir) if parent == dir { break } dir = parent } + return "" +} - return gitRoot, nil +// 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. diff --git a/go/internal/git/helpers.go b/go/internal/git/helpers.go deleted file mode 100644 index d402a6c78..000000000 --- a/go/internal/git/helpers.go +++ /dev/null @@ -1,27 +0,0 @@ -package git - -import ( - "encoding/json" - "os" -) - -func readFileIfExists(path string) ([]byte, error) { - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - return data, nil -} - -func hasWorkspaces(data []byte) bool { - var pkg struct { - Workspaces json.RawMessage `json:"workspaces"` - } - if err := json.Unmarshal(data, &pkg); err != nil { - return false - } - return len(pkg.Workspaces) > 0 -} diff --git a/go/internal/monorepo/package_infos.go b/go/internal/monorepo/package_infos.go index c56b59c05..2e0ad279c 100644 --- a/go/internal/monorepo/package_infos.go +++ b/go/internal/monorepo/package_infos.go @@ -15,67 +15,91 @@ import ( // GetPackageInfos discovers all packages in the workspace. func GetPackageInfos(options *types.BeachballOptions) (types.PackageInfos, error) { rootPath := options.Path - rootPkgPath := filepath.Join(rootPath, "package.json") - - data, err := os.ReadFile(rootPkgPath) - if err != nil { - return nil, err - } - - var rootPkg types.PackageJson - if err := json.Unmarshal(data, &rootPkg); err != nil { - return nil, err - } - infos := make(types.PackageInfos) - if len(rootPkg.Workspaces) == 0 { - // Single package repo + 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") + data, err := os.ReadFile(rootPkgPath) + if err != nil { + return nil, err + } + var rootPkg types.PackageJson + if err := json.Unmarshal(data, &rootPkg); err != nil { + return nil, err + } info := packageInfoFromJSON(&rootPkg, rootPkgPath) infos[info.Name] = info return infos, nil } - // Monorepo: add root package - rootInfo := packageInfoFromJSON(&rootPkg, rootPkgPath) - infos[rootInfo.Name] = rootInfo - - // Glob for workspace packages - for _, pattern := range rootPkg.Workspaces { - pkgPattern := filepath.Join(rootPath, pattern, "package.json") - // Use doublestar for glob matching - matches, err := doublestar.FilepathGlob(pkgPattern) - if err != nil { - continue + // Monorepo: add root package if it exists + rootPkgPath := filepath.Join(rootPath, "package.json") + if data, err := os.ReadFile(rootPkgPath); err == nil { + var rootPkg types.PackageJson + if err := json.Unmarshal(data, &rootPkg); err == nil && rootPkg.Name != "" { + rootInfo := packageInfoFromJSON(&rootPkg, rootPkgPath) + infos[rootInfo.Name] = rootInfo } - for _, match := range matches { - if strings.Contains(match, "node_modules") { - continue + } + + 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 } - pkgData, err := os.ReadFile(match) + } + } 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 } - var pkg types.PackageJson - if err := json.Unmarshal(pkgData, &pkg); err != nil { - continue - } - absMatch, _ := filepath.Abs(match) - 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 nil, fmt.Errorf("duplicate package name: %s", info.Name) + 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 + } } - infos[info.Name] = info } } 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 { + pkgData, err := os.ReadFile(pkgJsonPath) + if err != nil { + return nil // skip missing files silently + } + var pkg types.PackageJson + if err := json.Unmarshal(pkgData, &pkg); err != nil { + return nil // skip 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{ diff --git a/go/internal/monorepo/workspace_manager.go b/go/internal/monorepo/workspace_manager.go new file mode 100644 index 000000000..7d308a6b3 --- /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") +} From 1fcf83c1124a99d99af454e2dfbe27336b76b187 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Wed, 11 Mar 2026 14:10:39 -0700 Subject: [PATCH 30/38] rust: add more tests --- .vscode/settings.json | 3 +- rust/tests/are_change_files_deleted_test.rs | 3 ++ rust/tests/change_test.rs | 11 ++++++ rust/tests/changed_packages_test.rs | 44 ++++++++++++++++++++- rust/tests/disallowed_change_types_test.rs | 12 ++++++ rust/tests/package_groups_test.rs | 9 +++++ rust/tests/path_included_test.rs | 8 ++++ rust/tests/validate_test.rs | 3 ++ rust/tests/write_change_files_test.rs | 6 +++ 9 files changed, 96 insertions(+), 3 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 9a9c7ec14..bbac540d0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,7 +13,8 @@ "**/node_modules": true, "**/lib": true, "**/*.svg": true, - "**/.yarn": true + "**/.yarn": true, + "**/target": true }, "[rust]": { "editor.defaultFormatter": "rust-lang.rust-analyzer", diff --git a/rust/tests/are_change_files_deleted_test.rs b/rust/tests/are_change_files_deleted_test.rs index e1f50c705..5bf6f82cb 100644 --- a/rust/tests/are_change_files_deleted_test.rs +++ b/rust/tests/are_change_files_deleted_test.rs @@ -6,6 +6,7 @@ use common::change_files::generate_change_files; use common::repository_factory::RepositoryFactory; use common::{DEFAULT_BRANCH, make_test_options}; +// TS: "is false when no change files are deleted" #[test] fn is_false_when_no_change_files_are_deleted() { let factory = RepositoryFactory::new("monorepo"); @@ -21,6 +22,7 @@ fn is_false_when_no_change_files_are_deleted() { 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"); @@ -45,6 +47,7 @@ fn is_true_when_change_files_are_deleted() { assert!(are_change_files_deleted(&options)); } +// TS: "deletes change files when changeDir option is specified" #[test] fn works_with_custom_change_dir() { let factory = RepositoryFactory::new("monorepo"); diff --git a/rust/tests/change_test.rs b/rust/tests/change_test.rs index 8795879d9..34fdfbef6 100644 --- a/rust/tests/change_test.rs +++ b/rust/tests/change_test.rs @@ -27,6 +27,7 @@ fn make_repo_opts() -> BeachballOptions { } } +// 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"); @@ -44,6 +45,7 @@ fn does_not_create_change_files_when_no_changes() { 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"); @@ -74,6 +76,7 @@ fn creates_change_file_with_type_and_message() { 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"); @@ -113,6 +116,7 @@ fn creates_and_stages_a_change_file() { 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"); @@ -140,6 +144,7 @@ fn creates_and_commits_a_change_file() { 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"); @@ -172,6 +177,7 @@ fn creates_and_commits_a_change_file_with_change_dir() { 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"); @@ -199,6 +205,7 @@ fn creates_change_file_when_no_changes_but_package_provided() { 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"); @@ -229,6 +236,10 @@ fn creates_and_commits_change_files_for_multiple_packages() { 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"); diff --git a/rust/tests/changed_packages_test.rs b/rust/tests/changed_packages_test.rs index a74afde31..f54be8b63 100644 --- a/rust/tests/changed_packages_test.rs +++ b/rust/tests/changed_packages_test.rs @@ -38,8 +38,35 @@ fn check_out_test_branch(repo: &common::repository::Repository, name: &str) { repo.checkout(&["-b", &branch_name, DEFAULT_BRANCH]); } -// ===== Basic tests ===== +// ===== 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"); @@ -50,6 +77,7 @@ fn returns_empty_list_when_no_changes() { 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"); @@ -71,6 +99,7 @@ fn returns_package_name_when_changes_in_branch() { 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"); @@ -83,6 +112,7 @@ fn returns_empty_list_for_changelog_changes() { 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"); @@ -109,6 +139,7 @@ fn returns_given_package_names_as_is() { 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"); @@ -124,8 +155,9 @@ fn returns_all_packages_with_all_true() { assert_eq!(result, vec!["a", "b", "bar", "baz", "foo"]); } -// ===== Single package tests ===== +// ===== 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"); @@ -143,6 +175,7 @@ fn detects_changed_files_in_single_package_repo() { assert_eq!(result, vec!["foo"]); } +// TS: "respects ignorePatterns option" #[test] fn respects_ignore_patterns() { let factory = RepositoryFactory::new("single"); @@ -176,6 +209,7 @@ fn respects_ignore_patterns() { // ===== Monorepo tests ===== +// TS: "detects changed files in monorepo" #[test] fn detects_changed_files_in_monorepo() { let factory = RepositoryFactory::new("monorepo"); @@ -193,6 +227,7 @@ fn detects_changed_files_in_monorepo() { 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"); @@ -221,6 +256,10 @@ fn excludes_packages_with_existing_change_files() { 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([ @@ -281,6 +320,7 @@ fn ignores_package_changes_as_appropriate() { 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"); diff --git a/rust/tests/disallowed_change_types_test.rs b/rust/tests/disallowed_change_types_test.rs index 8bec4a5d1..f7bc64abd 100644 --- a/rust/tests/disallowed_change_types_test.rs +++ b/rust/tests/disallowed_change_types_test.rs @@ -20,6 +20,13 @@ fn make_infos_with_disallowed(name: &str, disallowed: Vec) -> Packag 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(); @@ -28,6 +35,7 @@ fn returns_none_for_unknown_package() { 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"); @@ -38,6 +46,7 @@ fn falls_back_to_repo_option() { 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]); @@ -47,6 +56,7 @@ fn returns_package_level_disallowed() { 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"); @@ -64,6 +74,7 @@ fn returns_group_level_disallowed() { 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]); @@ -81,6 +92,7 @@ fn returns_package_level_if_not_in_group() { 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]); diff --git a/rust/tests/package_groups_test.rs b/rust/tests/package_groups_test.rs index 425fcfc07..44ed18e00 100644 --- a/rust/tests/package_groups_test.rs +++ b/rust/tests/package_groups_test.rs @@ -4,6 +4,7 @@ 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(); @@ -12,6 +13,7 @@ fn returns_empty_if_no_groups_defined() { assert!(result.is_empty()); } +// TS: "returns groups based on specific folders" #[test] fn returns_groups_based_on_specific_folders() { let root = fake_root(); @@ -52,6 +54,7 @@ fn returns_groups_based_on_specific_folders() { assert_eq!(grp2_pkgs, vec!["pkg-c", "pkg-d"]); } +// TS: "handles single-level globs" #[test] fn handles_single_level_globs() { let root = fake_root(); @@ -77,6 +80,7 @@ fn handles_single_level_globs() { 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(); @@ -102,6 +106,7 @@ fn handles_multi_level_globs() { 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(); @@ -130,6 +135,7 @@ fn handles_multiple_include_patterns() { assert_eq!(pkgs, vec!["comp-b", "ui-a"]); } +// TS: "handles specific exclude patterns" #[test] fn handles_specific_exclude_patterns() { let root = fake_root(); @@ -155,6 +161,7 @@ fn handles_specific_exclude_patterns() { assert_eq!(pkgs, vec!["pkg-a", "pkg-b"]); } +// TS: "handles glob exclude patterns" #[test] fn handles_glob_exclude_patterns() { let root = fake_root(); @@ -178,6 +185,7 @@ fn handles_glob_exclude_patterns() { 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(); @@ -207,6 +215,7 @@ fn errors_if_package_in_multiple_groups() { assert!(err_msg.contains("multiple groups")); } +// TS: "omits empty groups" #[test] fn omits_empty_groups() { let root = fake_root(); diff --git a/rust/tests/path_included_test.rs b/rust/tests/path_included_test.rs index 82cf73a48..9660cfee0 100644 --- a/rust/tests/path_included_test.rs +++ b/rust/tests/path_included_test.rs @@ -1,10 +1,12 @@ 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( @@ -13,6 +15,7 @@ fn returns_false_if_path_is_excluded_single_exclude() { )); } +// TS: "returns true if path is included (multiple include paths)" #[test] fn returns_true_if_path_is_included_multiple_include() { assert!(is_path_included( @@ -25,6 +28,7 @@ fn returns_true_if_path_is_included_multiple_include() { )); } +// TS: "returns false if path is excluded (multiple exclude paths)" #[test] fn returns_false_if_path_is_excluded_multiple_exclude() { assert!(!is_path_included( @@ -37,21 +41,25 @@ fn returns_false_if_path_is_excluded_multiple_exclude() { )); } +// 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/validate_test.rs b/rust/tests/validate_test.rs index 44318d16b..aff191076 100644 --- a/rust/tests/validate_test.rs +++ b/rust/tests/validate_test.rs @@ -21,6 +21,7 @@ fn validate_wrapper( validate(&parsed, &validate_options) } +// TS: "succeeds with no changes" #[test] fn succeeds_with_no_changes() { let factory = RepositoryFactory::new("monorepo"); @@ -43,6 +44,7 @@ fn succeeds_with_no_changes() { 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"); @@ -67,6 +69,7 @@ fn exits_with_error_if_change_files_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"); diff --git a/rust/tests/write_change_files_test.rs b/rust/tests/write_change_files_test.rs index b2947922e..3a02f55ec 100644 --- a/rust/tests/write_change_files_test.rs +++ b/rust/tests/write_change_files_test.rs @@ -25,6 +25,10 @@ fn make_changes() -> Vec { ] } +// 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"); @@ -52,6 +56,7 @@ fn writes_individual_change_files() { ); } +// TS: "respects changeDir option" #[test] fn respects_change_dir_option() { let factory = RepositoryFactory::new("monorepo"); @@ -81,6 +86,7 @@ fn respects_change_dir_option() { } } +// TS: "respects commit=false" #[test] fn respects_commit_false() { let factory = RepositoryFactory::new("monorepo"); From 219e985d7258436b68f6fe22e0e9e7afeb5621b3 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Wed, 11 Mar 2026 14:24:22 -0700 Subject: [PATCH 31/38] rust: improve project root logic --- rust/Cargo.lock | 26 ++++ rust/Cargo.toml | 1 + rust/src/git/commands.rs | 51 +++++--- rust/src/monorepo/mod.rs | 1 + rust/src/monorepo/package_infos.rs | 87 ++++++++----- rust/src/monorepo/workspace_manager.rs | 162 +++++++++++++++++++++++++ rust/tests/workspace_manager_test.rs | 102 ++++++++++++++++ 7 files changed, 382 insertions(+), 48 deletions(-) create mode 100644 rust/src/monorepo/workspace_manager.rs create mode 100644 rust/tests/workspace_manager_test.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 07de45da6..e8921e41e 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -77,6 +77,7 @@ dependencies = [ "globset", "serde", "serde_json", + "serde_yaml", "tempfile", "uuid", ] @@ -377,6 +378,12 @@ dependencies = [ "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" @@ -426,6 +433,19 @@ dependencies = [ "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" @@ -468,6 +488,12 @@ 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" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index dfb02b62a..dc6a43e77 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" 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"] } diff --git a/rust/src/git/commands.rs b/rust/src/git/commands.rs index e7b720ea8..f80f78305 100644 --- a/rust/src/git/commands.rs +++ b/rust/src/git/commands.rs @@ -1,5 +1,5 @@ use anyhow::{Context, Result, bail}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; /// Result of running a git command. @@ -46,35 +46,46 @@ pub fn find_git_root(cwd: &str) -> Result { git_stdout(&["rev-parse", "--show-toplevel"], cwd) } -/// Find the project root (directory with package.json containing "workspaces", or git root). -pub fn find_project_root(cwd: &str) -> Result { - let git_root = find_git_root(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 from cwd up to git root looking for package.json with workspaces +/// 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(); - let git_root_path = Path::new(&git_root); + if let Ok(abs) = std::fs::canonicalize(&dir) { + dir = abs; + } loop { - let pkg_json = dir.join("package.json"); - if pkg_json.exists() - && let Ok(contents) = std::fs::read_to_string(&pkg_json) - && let Ok(pkg) = - serde_json::from_str::(&contents) - && pkg.workspaces.is_some() - { - return Ok(dir.to_string_lossy().to_string()); - } - - if dir == git_root_path { - break; + for f in files { + let candidate = dir.join(f); + if candidate.exists() { + return Some(candidate); + } } if !dir.pop() { break; } } + None +} - // Fall back to git root - Ok(git_root) +/// 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) { + if let Some(parent) = found.parent() { + return Ok(parent.to_string_lossy().to_string()); + } + } + find_git_root(cwd) } /// Get the current branch name. diff --git a/rust/src/monorepo/mod.rs b/rust/src/monorepo/mod.rs index cf6086ff9..d710495da 100644 --- a/rust/src/monorepo/mod.rs +++ b/rust/src/monorepo/mod.rs @@ -3,3 +3,4 @@ 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_infos.rs b/rust/src/monorepo/package_infos.rs index a3cdfc4f1..8e5a3f154 100644 --- a/rust/src/monorepo/package_infos.rs +++ b/rust/src/monorepo/package_infos.rs @@ -1,26 +1,53 @@ use anyhow::{Result, bail}; use std::path::{Path, PathBuf}; +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 root_pkg_path = Path::new(cwd).join("package.json"); + let mut infos = PackageInfos::new(); - if !root_pkg_path.exists() { - bail!("No package.json found at {cwd}"); - } + let manager = detect_workspace_manager(cwd); + let (patterns, literal) = get_workspace_patterns(cwd, manager); - let root_pkg: PackageJson = serde_json::from_str(&std::fs::read_to_string(&root_pkg_path)?)?; + 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); + } - let mut infos = PackageInfos::new(); + // Monorepo: add root package if it exists + let root_pkg_path = Path::new(cwd).join("package.json"); + if root_pkg_path.exists() { + if let Ok(info) = read_package_info(&root_pkg_path) { + if !info.name.is_empty() { + let name = info.name.clone(); + infos.insert(name, info); + } + } + } - if let Some(ref workspaces) = root_pkg.workspaces { - // Monorepo: glob each workspace pattern - for ws_pattern in workspaces { - let full_pattern = Path::new(cwd).join(ws_pattern); + 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) @@ -28,27 +55,15 @@ pub fn get_package_infos(options: &BeachballOptions) -> Result { for entry in entries.flatten() { let pkg_json_path = entry.join("package.json"); - if pkg_json_path.exists() - && let Ok(info) = read_package_info(&pkg_json_path) - { - if infos.contains_key(&info.name) { - 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); + 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)?; } } } - } else { - // Single package repo - let info = read_package_info(&root_pkg_path)?; - let name = info.name.clone(); - infos.insert(name, info); } // Apply package-level options from CLI if needed @@ -57,6 +72,22 @@ pub fn get_package_infos(options: &BeachballOptions) -> Result { 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) { + 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)?; diff --git a/rust/src/monorepo/workspace_manager.rs b/rust/src/monorepo/workspace_manager.rs new file mode 100644 index 000000000..3cef72c93 --- /dev/null +++ b/rust/src/monorepo/workspace_manager.rs @@ -0,0 +1,162 @@ +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) { + if let Some(ws) = parsed.workspaces { + if !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) { + if let Some(ws) = parsed.workspaces { + if let Some(pkgs) = ws.packages { + if !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) { + if let Some(pkgs) = config.packages { + if !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/tests/workspace_manager_test.rs b/rust/tests/workspace_manager_test.rs new file mode 100644 index 000000000..535434c34 --- /dev/null +++ b/rust/tests/workspace_manager_test.rs @@ -0,0 +1,102 @@ +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::repository_factory::RepositoryFactory; +use common::DEFAULT_REMOTE_BRANCH; +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:?}"); +} From 4c2777d866cf2f385608805742935bd4068e9d02 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Wed, 11 Mar 2026 14:55:48 -0700 Subject: [PATCH 32/38] rust: more logging and test fixes --- rust/src/changefile/changed_packages.rs | 49 ++- rust/src/changefile/read_change_files.rs | 31 +- rust/src/logging.rs | 45 ++- rust/src/monorepo/filter_ignored.rs | 7 +- rust/src/monorepo/package_groups.rs | 7 + rust/src/monorepo/package_infos.rs | 9 + .../validation/are_change_files_deleted.rs | 14 + rust/tests/are_change_files_deleted_test.rs | 8 +- rust/tests/changed_packages_test.rs | 12 +- rust/tests/common/mod.rs | 8 +- rust/tests/read_change_files_test.rs | 309 ++++++++++++++++++ 11 files changed, 446 insertions(+), 53 deletions(-) create mode 100644 rust/tests/read_change_files_test.rs diff --git a/rust/src/changefile/changed_packages.rs b/rust/src/changefile/changed_packages.rs index 3b2ecb7d1..ab92983df 100644 --- a/rust/src/changefile/changed_packages.rs +++ b/rust/src/changefile/changed_packages.rs @@ -5,6 +5,7 @@ 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; @@ -65,23 +66,23 @@ fn get_all_changed_packages( scoped_packages: &ScopedPackages, ) -> Result> { let cwd = &options.path; - let verbose = options.verbose; // If --all, return all in-scope non-private packages if options.all { - if verbose { - log_info!( - "--all option was provided, so including all packages that are in scope (regardless of changes)" - ); + log_verbose!( + "--all option was provided, so including all packages that are in scope (regardless of changes)" + ); + let mut result: Vec = Vec::new(); + 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); + } } - let result: Vec = package_infos - .values() - .filter(|pkg| { - let (included, _reason) = is_package_included(Some(pkg), scoped_packages); - included - }) - .map(|pkg| pkg.name.clone()) - .collect(); return Ok(result); } @@ -100,9 +101,9 @@ fn get_all_changed_packages( let staged = commands::get_staged_changes(cwd)?; changes.extend(staged); - if verbose { + { let count = changes.len(); - log_info!( + log_verbose!( "Found {} changed file{} in current branch (before filtering)", count, if count == 1 { "" } else { "s" } @@ -139,12 +140,10 @@ fn get_all_changed_packages( }) .collect(); - let non_ignored = filter_ignored_files(&changes, &expanded_patterns, verbose); + let non_ignored = filter_ignored_files(&changes, &expanded_patterns); if non_ignored.is_empty() { - if verbose { - log_info!("All files were ignored"); - } + log_verbose!("All files were ignored"); return Ok(vec![]); } @@ -170,21 +169,17 @@ fn get_all_changed_packages( let (included, reason) = is_package_included(pkg_info, scoped_packages); if !included { - if verbose { - log_info!(" - ~~{file}~~ ({reason})"); - } + log_verbose!(" - ~~{file}~~ ({reason})"); } else { included_packages.insert(pkg_info.unwrap().name.clone()); file_count += 1; - if verbose { - log_info!(" - {file}"); - } + log_verbose!(" - {file}"); } } - if verbose { + { let pkg_count = included_packages.len(); - log_info!( + log_verbose!( "Found {} file{} in {} package{} that should be published", file_count, if file_count == 1 { "" } else { "s" }, diff --git a/rust/src/changefile/read_change_files.rs b/rust/src/changefile/read_change_files.rs index 653019fb6..1e3df7341 100644 --- a/rust/src/changefile/read_change_files.rs +++ b/rust/src/changefile/read_change_files.rs @@ -68,19 +68,36 @@ pub fn read_change_files( } else if let Ok(single) = serde_json::from_str::(&contents) { vec![single] } else { - log_warn!("Could not parse change file {filename}"); + log_warn!("{} does not appear to be a change file", file_path.display()); continue; }; for change in changes { - // Filter: package must exist, not be private, and be in scope - if !package_infos.contains_key(&change.package_name) { - continue; - } - let info = &package_infos[&change.package_name]; - if info.private { + // 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; } diff --git a/rust/src/logging.rs b/rust/src/logging.rs index 7775a8ec5..6df6244f8 100644 --- a/rust/src/logging.rs +++ b/rust/src/logging.rs @@ -1,8 +1,8 @@ // User-facing logging with test capture support. // -// All CLI output goes through log_info!/log_warn!/log_error! macros instead of -// println!/eprintln! directly. This allows tests to capture and assert on output -// (matching the TS tests' jest.spyOn(console, ...) pattern). +// 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 @@ -18,16 +18,31 @@ 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. +/// 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. @@ -45,14 +60,23 @@ 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 => writeln!(buf, "{msg}").ok(), + Level::Info | Level::Verbose => writeln!(buf, "{msg}").ok(), Level::Warn => writeln!(buf, "WARN: {msg}").ok(), Level::Error => writeln!(buf, "ERROR: {msg}").ok(), }; @@ -60,7 +84,7 @@ pub fn write_log(level: Level, msg: &str) { } drop(borrow); match level { - Level::Info => println!("{msg}"), + Level::Info | Level::Verbose => println!("{msg}"), Level::Warn => eprintln!("WARN: {msg}"), Level::Error => eprintln!("ERROR: {msg}"), } @@ -91,11 +115,18 @@ macro_rules! log_error { }; } +#[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}")) + .map(|item| format!(" - {item}")) .collect::>() .join("\n") } diff --git a/rust/src/monorepo/filter_ignored.rs b/rust/src/monorepo/filter_ignored.rs index 3b8b99c90..379ed80e2 100644 --- a/rust/src/monorepo/filter_ignored.rs +++ b/rust/src/monorepo/filter_ignored.rs @@ -1,21 +1,18 @@ use super::path_included::match_with_base; -use crate::log_info; +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], - verbose: bool, ) -> Vec { file_paths .iter() .filter(|path| { for pattern in ignore_patterns { if match_with_base(path, pattern) { - if verbose { - log_info!(" - ~~{path}~~ (ignored by pattern \"{pattern}\")"); - } + log_verbose!(" - ~~{path}~~ (ignored by pattern \"{pattern}\")"); return false; } } diff --git a/rust/src/monorepo/package_groups.rs b/rust/src/monorepo/package_groups.rs index d16cac13e..9f8638011 100644 --- a/rust/src/monorepo/package_groups.rs +++ b/rust/src/monorepo/package_groups.rs @@ -1,5 +1,6 @@ use anyhow::{Result, bail}; +use crate::log_error; use crate::types::options::VersionGroupInclude; use crate::types::package_info::{PackageGroupInfo, PackageGroups, PackageInfos}; @@ -46,6 +47,12 @@ pub fn get_package_groups( // 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, diff --git a/rust/src/monorepo/package_infos.rs b/rust/src/monorepo/package_infos.rs index 8e5a3f154..a67d588ba 100644 --- a/rust/src/monorepo/package_infos.rs +++ b/rust/src/monorepo/package_infos.rs @@ -1,6 +1,7 @@ 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}; @@ -75,6 +76,14 @@ pub fn get_package_infos(options: &BeachballOptions) -> Result { 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, diff --git a/rust/src/validation/are_change_files_deleted.rs b/rust/src/validation/are_change_files_deleted.rs index 632f019f6..aa743160c 100644 --- a/rust/src/validation/are_change_files_deleted.rs +++ b/rust/src/validation/are_change_files_deleted.rs @@ -1,10 +1,16 @@ 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"), @@ -13,5 +19,13 @@ pub fn are_change_files_deleted(options: &BeachballOptions) -> bool { ) .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/tests/are_change_files_deleted_test.rs b/rust/tests/are_change_files_deleted_test.rs index 5bf6f82cb..ee88adcca 100644 --- a/rust/tests/are_change_files_deleted_test.rs +++ b/rust/tests/are_change_files_deleted_test.rs @@ -4,7 +4,9 @@ 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, make_test_options}; +use common::{ + DEFAULT_BRANCH, capture_logging, get_log_output, make_test_options, reset_logging, +}; // TS: "is false when no change files are deleted" #[test] @@ -43,8 +45,12 @@ fn is_true_when_change_files_are_deleted() { 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" diff --git a/rust/tests/changed_packages_test.rs b/rust/tests/changed_packages_test.rs index f54be8b63..ea75aa16a 100644 --- a/rust/tests/changed_packages_test.rs +++ b/rust/tests/changed_packages_test.rs @@ -8,7 +8,7 @@ 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, + DEFAULT_BRANCH, DEFAULT_REMOTE_BRANCH, capture_verbose_logging, get_log_output, reset_logging, }; use serde_json::json; use std::collections::HashMap; @@ -89,7 +89,7 @@ fn returns_package_name_when_changes_in_branch() { verbose: true, ..Default::default() }; - capture_logging(); + 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(); @@ -198,13 +198,14 @@ fn respects_ignore_patterns() { repo.write_file_content("yarn.lock", "changed"); repo.git(&["add", "-A"]); - capture_logging(); + 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 ===== @@ -242,13 +243,14 @@ fn excludes_packages_with_existing_change_files() { let (options, infos, scoped) = get_options_and_packages(&repo, Some(opts), None); generate_change_files(&["foo"], &options, &repo); - capture_logging(); + 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"); @@ -309,7 +311,7 @@ fn ignores_package_changes_as_appropriate() { ..Default::default() }; - capture_logging(); + 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(); diff --git a/rust/tests/common/mod.rs b/rust/tests/common/mod.rs index d40044df9..876e9540c 100644 --- a/rust/tests/common/mod.rs +++ b/rust/tests/common/mod.rs @@ -14,12 +14,18 @@ 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. +/// 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 { 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 From a2c26f7e9f18c0152da8313166d0c4ab971659a7 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Wed, 11 Mar 2026 15:25:57 -0700 Subject: [PATCH 33/38] rust: modernize --- rust/src/changefile/changed_packages.rs | 2 +- rust/src/changefile/read_change_files.rs | 2 +- rust/src/changefile/write_change_files.rs | 2 +- rust/src/git/commands.rs | 10 +++++----- rust/src/logging.rs | 2 +- rust/src/monorepo/package_groups.rs | 2 +- rust/src/validation/validate.rs | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/rust/src/changefile/changed_packages.rs b/rust/src/changefile/changed_packages.rs index ab92983df..c142ba15a 100644 --- a/rust/src/changefile/changed_packages.rs +++ b/rust/src/changefile/changed_packages.rs @@ -72,7 +72,7 @@ fn get_all_changed_packages( log_verbose!( "--all option was provided, so including all packages that are in scope (regardless of changes)" ); - let mut result: Vec = Vec::new(); + let mut result: Vec = vec![]; for pkg in package_infos.values() { let (included, reason) = is_package_included(Some(pkg), scoped_packages); if included { diff --git a/rust/src/changefile/read_change_files.rs b/rust/src/changefile/read_change_files.rs index 1e3df7341..2de5e7916 100644 --- a/rust/src/changefile/read_change_files.rs +++ b/rust/src/changefile/read_change_files.rs @@ -26,7 +26,7 @@ pub fn read_change_files( return vec![]; } - let mut entries: Vec<(String, std::time::SystemTime)> = Vec::new(); + 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() { diff --git a/rust/src/changefile/write_change_files.rs b/rust/src/changefile/write_change_files.rs index 22140c33d..265b706fa 100644 --- a/rust/src/changefile/write_change_files.rs +++ b/rust/src/changefile/write_change_files.rs @@ -26,7 +26,7 @@ pub fn write_change_files( std::fs::create_dir_all(&change_path)?; } - let mut change_files: Vec = Vec::new(); + let mut change_files: Vec = vec![]; if options.group_changes { // Write all changes to a single grouped file diff --git a/rust/src/git/commands.rs b/rust/src/git/commands.rs index f80f78305..365f08716 100644 --- a/rust/src/git/commands.rs +++ b/rust/src/git/commands.rs @@ -123,7 +123,7 @@ pub fn get_branch_changes(branch: &str, cwd: &str) -> Result> { .stdout .lines() .filter(|l| !l.is_empty()) - .map(|l| l.to_string()) + .map(str::to_string) .collect()) } @@ -147,7 +147,7 @@ pub fn get_staged_changes(cwd: &str) -> Result> { .stdout .lines() .filter(|l| !l.is_empty()) - .map(|l| l.to_string()) + .map(str::to_string) .collect()) } @@ -184,7 +184,7 @@ pub fn get_changes_between_refs( .stdout .lines() .filter(|l| !l.is_empty()) - .map(|l| l.to_string()) + .map(str::to_string) .collect()) } @@ -195,7 +195,7 @@ pub fn get_untracked_changes(cwd: &str) -> Result> { .stdout .lines() .filter(|l| !l.is_empty()) - .map(|l| l.to_string()) + .map(str::to_string) .collect()) } @@ -293,7 +293,7 @@ pub fn list_tracked_files(pattern: &str, cwd: &str) -> Result> { .stdout .lines() .filter(|l| !l.is_empty()) - .map(|l| l.to_string()) + .map(str::to_string) .collect()) } diff --git a/rust/src/logging.rs b/rust/src/logging.rs index 6df6244f8..770391562 100644 --- a/rust/src/logging.rs +++ b/rust/src/logging.rs @@ -50,7 +50,7 @@ pub fn get_output() -> String { LOG_CAPTURE.with(|c| { let borrow = c.borrow(); match &*borrow { - Some(buf) => String::from_utf8_lossy(buf).to_string(), + Some(buf) => String::from_utf8_lossy(buf).into_owned(), None => String::new(), } }) diff --git a/rust/src/monorepo/package_groups.rs b/rust/src/monorepo/package_groups.rs index 9f8638011..5fd44c6f8 100644 --- a/rust/src/monorepo/package_groups.rs +++ b/rust/src/monorepo/package_groups.rs @@ -24,7 +24,7 @@ pub fn get_package_groups( std::collections::HashMap::new(); for group in groups { - let mut package_names = Vec::new(); + let mut package_names = vec![]; for info in package_infos.values() { let rel_path = get_package_rel_path(info, root); diff --git a/rust/src/validation/validate.rs b/rust/src/validation/validate.rs index 8f4167660..ef2ad797a 100644 --- a/rust/src/validation/validate.rs +++ b/rust/src/validation/validate.rs @@ -82,7 +82,7 @@ pub fn validate( &mut has_error, ); } else if let Some(ref packages) = options.package { - let mut invalid_reasons: Vec = Vec::new(); + let mut invalid_reasons = vec![]; for pkg in packages { if !package_infos.contains_key(pkg) { invalid_reasons.push(format!("\"{pkg}\" was not found")); From 21add586894c50d9888e18b47078bb89e7041144 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Wed, 11 Mar 2026 16:52:50 -0700 Subject: [PATCH 34/38] formatting --- go/internal/monorepo/workspace_manager.go | 10 +++--- rust/src/changefile/changed_packages.rs | 4 ++- rust/src/changefile/read_change_files.rs | 5 ++- rust/src/monorepo/filter_ignored.rs | 5 +-- rust/tests/are_change_files_deleted_test.rs | 4 +-- rust/tests/workspace_manager_test.rs | 39 ++++++++++++++++----- 6 files changed, 44 insertions(+), 23 deletions(-) diff --git a/go/internal/monorepo/workspace_manager.go b/go/internal/monorepo/workspace_manager.go index 7d308a6b3..0c901a995 100644 --- a/go/internal/monorepo/workspace_manager.go +++ b/go/internal/monorepo/workspace_manager.go @@ -23,11 +23,11 @@ const ( // 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, + "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. diff --git a/rust/src/changefile/changed_packages.rs b/rust/src/changefile/changed_packages.rs index c142ba15a..55bfb4ecb 100644 --- a/rust/src/changefile/changed_packages.rs +++ b/rust/src/changefile/changed_packages.rs @@ -79,7 +79,9 @@ fn get_all_changed_packages( log_verbose!(" - {}", pkg.name); result.push(pkg.name.clone()); } else { - let short_reason = reason.strip_prefix(&format!("{} ", pkg.name)).unwrap_or(&reason); + let short_reason = reason + .strip_prefix(&format!("{} ", pkg.name)) + .unwrap_or(&reason); log_verbose!(" - ~~{}~~ ({})", pkg.name, short_reason); } } diff --git a/rust/src/changefile/read_change_files.rs b/rust/src/changefile/read_change_files.rs index 2de5e7916..db6332766 100644 --- a/rust/src/changefile/read_change_files.rs +++ b/rust/src/changefile/read_change_files.rs @@ -68,7 +68,10 @@ pub fn read_change_files( } 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()); + log_warn!( + "{} does not appear to be a change file", + file_path.display() + ); continue; }; diff --git a/rust/src/monorepo/filter_ignored.rs b/rust/src/monorepo/filter_ignored.rs index 379ed80e2..93464755c 100644 --- a/rust/src/monorepo/filter_ignored.rs +++ b/rust/src/monorepo/filter_ignored.rs @@ -3,10 +3,7 @@ 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 { +pub fn filter_ignored_files(file_paths: &[String], ignore_patterns: &[String]) -> Vec { file_paths .iter() .filter(|path| { diff --git a/rust/tests/are_change_files_deleted_test.rs b/rust/tests/are_change_files_deleted_test.rs index ee88adcca..58cdd8e23 100644 --- a/rust/tests/are_change_files_deleted_test.rs +++ b/rust/tests/are_change_files_deleted_test.rs @@ -4,9 +4,7 @@ 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, -}; +use common::{DEFAULT_BRANCH, capture_logging, get_log_output, make_test_options, reset_logging}; // TS: "is false when no change files are deleted" #[test] diff --git a/rust/tests/workspace_manager_test.rs b/rust/tests/workspace_manager_test.rs index 535434c34..5c29ebda1 100644 --- a/rust/tests/workspace_manager_test.rs +++ b/rust/tests/workspace_manager_test.rs @@ -3,8 +3,8 @@ 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::repository_factory::RepositoryFactory; use common::DEFAULT_REMOTE_BRANCH; +use common::repository_factory::RepositoryFactory; use serde_json::json; use std::collections::HashMap; @@ -24,8 +24,14 @@ fn pnpm_workspace_detection() { "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"})), + ( + "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(); @@ -51,8 +57,14 @@ fn lerna_workspace_detection() { "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"})), + ( + "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(); @@ -77,20 +89,29 @@ fn rush_workspace_detection() { "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"})), + ( + "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#"{ + 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"]); From 7326dd660a7004bb04b865c875e8493d903d01c3 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Wed, 11 Mar 2026 17:46:55 -0700 Subject: [PATCH 35/38] fix git commands --- CLAUDE.md | 121 +++---- go/internal/git/commands.go | 234 ++++++++++++-- go/internal/git/commands_test.go | 476 ++++++++++++++++++++++++++++ go/internal/options/get_options.go | 10 + go/internal/options/repo_options.go | 49 +-- rust/src/git/commands.rs | 257 +++++++++++---- rust/src/options/get_options.rs | 4 +- rust/src/options/repo_options.rs | 71 ++--- rust/tests/git_commands_test.rs | 399 +++++++++++++++++++++++ 9 files changed, 1381 insertions(+), 240 deletions(-) create mode 100644 go/internal/git/commands_test.go create mode 100644 rust/tests/git_commands_test.rs diff --git a/CLAUDE.md b/CLAUDE.md index 9a3cc4d84..bae3d3c11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Beachball is a semantic version bumping tool for monorepos. It manages change files, calculates version bumps, generates changelogs, and publishes packages to npm registries. +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 @@ -12,7 +12,6 @@ Beachball is a semantic version bumping tool for monorepos. It manages change fi ```bash yarn build # Compile TypeScript to lib/ -yarn start # Watch mode with preserveWatchOutput ``` ### Testing @@ -22,7 +21,6 @@ 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 test:watch # Watch mode yarn update-snapshots # Update all test snapshots ``` @@ -47,18 +45,17 @@ yarn lint:deps # Depcheck only yarn format # Format with Prettier ``` -### Development Workflow +### Final steps before PR ```bash yarn change --type minor|patch --message "message" # Create a change file -yarn checkchange # Verify change files exist for modified packages ``` ## Architecture Overview ### Core Processing Flow -Beachball follows a **two-phase architecture** (calculate in-memory, then apply to disk): +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 @@ -84,49 +81,35 @@ Beachball follows a **two-phase architecture** (calculate in-memory, then apply - `init` - Repository initialization **Bump Logic** (`src/bump/bumpInMemory.ts`): -Five-pass algorithm that calculates version changes: - -1. Initialize change types from change files -2. Apply package group rules (synchronized versioning) -3. Propagate changes to dependents (if `bumpDeps: true`) -4. Bump package versions in memory -5. Update dependency version ranges +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`. - `readChangeFiles()` - Loads and validates from disk - `writeChangeFiles()` - Persists new change files - `unlinkChangeFiles()` - Deletes after consumption during bump -- Each file specifies: package name, change type (patch/minor/major), description, dependent change type **Publishing** (`src/publish/`): -- `publishToRegistry()` - Validates, applies publishConfig, runs hooks, publishes concurrently (respects dependency order) -- `bumpAndPush()` - Git operations: creates temp branch, fetches, merges, bumps, commits, tags, pushes (5 retries) -- Uses temporary `publish_*` branches for safety +- `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: `prebump`, `postbump`, `prepublish`, `postpublish`, `precommit` **Context Passing:** -`CommandContext` aggregates reusable data to avoid repeated calculations: - -- `originalPackageInfos` - Discovered packages -- `packageGroups` - Version groups (packages versioned together) -- `scopedPackages` - Filtered set after scoping -- `changeSet` - Validated change files -- `bumpInfo` - Calculated version changes (only if pre-calculated) +`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:** -- In-memory calculations return new objects, don't mutate inputs -- `cloneObject()` creates defensive copies -- Separate calculation (`bumpInMemory`) from application (`performBump`) +- `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 -- Provides early failure and context for execution +- `validate()` runs before most commands to validate config and repo state - Pre-calculates expensive operations when needed **Package Groups:** @@ -141,12 +124,10 @@ Five-pass algorithm that calculates version changes: - Controlled by `dependentChangeType` in change files and `bumpDeps` option - Propagation respects package groups -### Critical Implementation Details - **Change Type Hierarchy:** -- `none` < `patch` < `minor` < `major` -- Groups can specify `disallowedChangeTypes` (this repo disallows `major`) +- Defined in `src/changefile/changeTypes.ts` `SortedChangeTypes` +- Packages, groups, and the repo config can specify `disallowedChangeTypes` **Scoping:** @@ -154,11 +135,6 @@ Five-pass algorithm that calculates version changes: - Based on git changes, explicit inclusion/exclusion, or package patterns - Affects change file validation, bumping, and publishing -**Lock File Handling:** - -- Automatically regenerated after version bumps -- Uses workspace-tools to detect package manager (npm/yarn/pnpm) - **Git Operations:** - All git commands use `gitAsync()` wrapper with logging @@ -167,39 +143,28 @@ Five-pass algorithm that calculates version changes: **Testing Structure:** -- Unit tests: `__tests__/` or `__fixtures__/` directories -- Functional tests: `__functional__/` directories -- E2E tests: `__e2e__/` directories +- Fixtures: `__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 -## Configuration - -The repo uses `beachball.config.js` with: - -- `disallowedChangeTypes: ['major']` - No major version bumps allowed -- `ignorePatterns` - Files/paths that don't require change files (docs, config, tests, yarn.lock) - ## TypeScript Configuration -- Target: ES2020 (Node 14+ compatible) -- Output: `lib/` directory (compiled JS + declarations) +- Current target: ES2020 (Node 14+ compatible) - Strict mode enabled with `noUnusedLocals` -- Source maps and declaration maps generated -- Checks both TS and JS files (`allowJs`, `checkJs`) ## Important Notes -- Beachball has **no public API** - only the CLI and configuration options are supported -- Change files are the source of truth for version bumps (not git commits) -- The `publish` command can run bump, registry publish, and git push independently via flags (`--no-bump`, `--no-publish`, `--no-push`) -- Package manager is auto-detected (supports npm, yarn, pnpm) -- Pre/post hooks available: `prebump`, `postbump`, `prepublish`, `postpublish`, `precommit` +- 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 parallel re-implementations of beachball's `check` and `change` commands. Both pass 16 tests covering changed package detection, validation, and change file creation. +The `rust/` and `go/` directories contain parallel re-implementations of beachball's `check` and `change` commands and the corresponding tests. ### Building and Testing @@ -215,41 +180,51 @@ go test ./... ### Scope -Both implement: CLI parsing, JSON config loading (`.beachballrc.json` and `package.json` `"beachball"` field — no JS configs), workspace detection (`workspaces` field), `getChangedPackages` (git diff + file-to-package mapping + change file dedup), `validate()` (minus `bumpInMemory`/dependency validation), non-interactive `change` command (`--type` + `--message`), and `check` command. +Both implement: + +- 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: -Not implemented: JS config files, interactive prompts, `bumpInMemory`, publish/bump/changelog, pnpm/rush/lerna workspaces. +- JS config files +- interactive prompts +- all bumping and publishing operations +- sync -### Implementation instructions +### Implementation requirements -The behavior, log messages, and tests as specified in the TypeScript code must be matched exactly 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. +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. -Where possible, attempt to use the LSP for the language instead of grep to understand the code. Also use the LSP to check for errors after making changes. +Where possible, use the LSP instead of grep to understand the code. Also use the LSP to check for errors after making changes. ### Structure -- **Rust**: `src/` with nested modules (`types/`, `options/`, `git/`, `monorepo/`, `changefile/`, `validation/`, `commands/`), integration tests in `tests/` with shared helpers in `tests/common/` -- **Go**: `cmd/beachball/` CLI entry, `internal/` packages (`types`, `options`, `git`, `monorepo`, `changefile`, `validation`, `commands`, `logging`), test helpers in `internal/testutil/`, tests alongside source (`_test.go`) +- **Rust**: `src/` with nested modules, integration tests in `tests/` with shared helpers in `tests/common/` +- **Go**: `cmd/beachball/` CLI entry, `internal/` packages, test helpers in `internal/testutil/`, tests alongside source (`_test.go`) ### Key Implementation Details -**Git commands**: Both shell out to `git` (matching the TS approach via workspace-tools). Critical flags from workspace-tools: `--no-pager`, `--relative`, `--no-renames`. The `--relative` flag makes diff output relative to cwd (not repo root). Three-dot range (`branch...`) is used for diffs. +**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`. -**Change file format**: Identical JSON to TS: `{ "type", "comment", "packageName", "email", "dependentChangeType" }`, named `{pkg}-{uuid}.json`. - ### 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. - -### Dependencies - -- **Rust**: clap (CLI), serde/serde_json (JSON), globset/glob (matching), uuid, anyhow, tempfile -- **Go**: cobra (CLI), doublestar (glob), google/uuid, standard library for the rest diff --git a/go/internal/git/commands.go b/go/internal/git/commands.go index d4ca83f0c..89cee2943 100644 --- a/go/internal/git/commands.go +++ b/go/internal/git/commands.go @@ -1,11 +1,17 @@ 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. @@ -71,9 +77,9 @@ var ManagerFiles = []string{ "package-lock.json", } -// SearchUp walks up the directory tree from cwd looking for any of the given files. +// 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 { +func searchUp(files []string, cwd string) string { absPath, err := filepath.Abs(cwd) if err != nil { return "" @@ -100,7 +106,7 @@ func SearchUp(files []string, cwd string) string { // 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 != "" { + if found := searchUp(ManagerFiles, cwd); found != "" { return filepath.Dir(found), nil } return FindGitRoot(cwd) @@ -123,7 +129,7 @@ func GetUserEmail(cwd string) string { // 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", "--no-renames", + "--no-pager", "diff", "--name-only", "--relative", fmt.Sprintf("%s...", branch), }, cwd) if err != nil { @@ -132,13 +138,13 @@ func GetBranchChanges(branch, cwd string) ([]string, error) { if !result.Success { return nil, nil } - return splitLines(result.Stdout), nil + return processGitOutput(result.Stdout), nil } // GetStagedChanges returns staged file changes. func GetStagedChanges(cwd string) ([]string, error) { result, err := Git([]string{ - "--no-pager", "diff", "--cached", "--name-only", "--relative", "--no-renames", + "--no-pager", "diff", "--staged", "--name-only", "--relative", }, cwd) if err != nil { return nil, err @@ -146,12 +152,12 @@ func GetStagedChanges(cwd string) ([]string, error) { if !result.Success { return nil, nil } - return splitLines(result.Stdout), 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", "--no-renames"} + args := []string{"--no-pager", "diff", "--name-only", "--relative"} if diffFilter != "" { args = append(args, fmt.Sprintf("--diff-filter=%s", diffFilter)) } @@ -167,22 +173,19 @@ func GetChangesBetweenRefs(fromRef string, diffFilter, pattern, cwd string) ([]s if !result.Success { return nil, nil } - return splitLines(result.Stdout), nil + return processGitOutput(result.Stdout), nil } -// GetUntrackedChanges returns untracked files. +// GetUntrackedChanges returns untracked files (not in index, respecting .gitignore). func GetUntrackedChanges(cwd string) ([]string, error) { - result, err := Git([]string{"status", "--short", "--untracked-files"}, cwd) + result, err := Git([]string{"ls-files", "--others", "--exclude-standard"}, cwd) if err != nil { return nil, err } - var files []string - for _, line := range splitLines(result.Stdout) { - if strings.HasPrefix(line, "??") { - files = append(files, strings.TrimSpace(line[2:])) - } + if !result.Success { + return nil, nil } - return files, nil + return processGitOutput(result.Stdout), nil } // Stage adds files to the staging area. @@ -213,7 +216,7 @@ func Fetch(remoteBranch, cwd string) error { if len(parts) != 2 { return fmt.Errorf("invalid remote branch format: %s", remoteBranch) } - _, err := gitStdout([]string{"fetch", parts[0], parts[1]}, cwd) + _, err := gitStdout([]string{"fetch", "--", parts[0], parts[1]}, cwd) return err } @@ -232,6 +235,201 @@ func HasRef(ref, cwd string) bool { 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 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/options/get_options.go b/go/internal/options/get_options.go index 30cec4627..a8af10823 100644 --- a/go/internal/options/get_options.go +++ b/go/internal/options/get_options.go @@ -41,6 +41,16 @@ func GetParsedOptions(cwd string, cli types.CliOptions) (types.ParsedOptions, er 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 } diff --git a/go/internal/options/repo_options.go b/go/internal/options/repo_options.go index 14a184c6c..f21650d35 100644 --- a/go/internal/options/repo_options.go +++ b/go/internal/options/repo_options.go @@ -5,7 +5,6 @@ import ( "os" "path/filepath" - "github.com/microsoft/beachball/internal/git" "github.com/microsoft/beachball/internal/types" ) @@ -26,45 +25,25 @@ type RepoConfig struct { Groups []types.VersionGroupOptions `json:"groups,omitempty"` } -// LoadRepoConfig searches for beachball config starting from cwd up to the git root. -func LoadRepoConfig(cwd string, configPath string) (*RepoConfig, error) { +// 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(configPath) + return loadConfigFile(filepath.Join(projectRoot, configPath)) } - gitRoot, err := git.FindGitRoot(cwd) - if err != nil { - gitRoot = cwd - } - - absPath, err := filepath.Abs(cwd) - if err != nil { - absPath = cwd + // Try .beachballrc.json + rcPath := filepath.Join(projectRoot, ".beachballrc.json") + if cfg, err := loadConfigFile(rcPath); err == nil { + return cfg, nil } - gitRootAbs, _ := filepath.Abs(gitRoot) - - dir := absPath - for { - // Try .beachballrc.json - rcPath := filepath.Join(dir, ".beachballrc.json") - if cfg, err := loadConfigFile(rcPath); err == nil { - return cfg, nil - } - - // Try package.json "beachball" field - pkgPath := filepath.Join(dir, "package.json") - if cfg, err := loadFromPackageJSON(pkgPath); err == nil && cfg != nil { - return cfg, nil - } - if dir == gitRootAbs { - break - } - parent := filepath.Dir(dir) - if parent == dir { - break - } - dir = parent + // 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 diff --git a/rust/src/git/commands.rs b/rust/src/git/commands.rs index 365f08716..9010971b3 100644 --- a/rust/src/git/commands.rs +++ b/rust/src/git/commands.rs @@ -111,7 +111,6 @@ pub fn get_branch_changes(branch: &str, cwd: &str) -> Result> { "diff", "--name-only", "--relative", - "--no-renames", &format!("{branch}..."), ], cwd, @@ -119,12 +118,7 @@ pub fn get_branch_changes(branch: &str, cwd: &str) -> Result> { if !result.success { return Ok(vec![]); } - Ok(result - .stdout - .lines() - .filter(|l| !l.is_empty()) - .map(str::to_string) - .collect()) + Ok(process_git_output(&result.stdout)) } /// Get staged changes. @@ -133,22 +127,16 @@ pub fn get_staged_changes(cwd: &str) -> Result> { &[ "--no-pager", "diff", - "--cached", + "--staged", "--name-only", "--relative", - "--no-renames", ], cwd, )?; if !result.success { return Ok(vec![]); } - Ok(result - .stdout - .lines() - .filter(|l| !l.is_empty()) - .map(str::to_string) - .collect()) + Ok(process_git_output(&result.stdout)) } /// Get changes between two refs, optionally filtering by pattern and diff filter. @@ -160,13 +148,7 @@ pub fn get_changes_between_refs( ) -> 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", - "--no-renames", - ]; + let mut args: Vec<&str> = vec!["--no-pager", "diff", "--name-only", "--relative"]; if let Some(ref flag) = diff_flag { args.push(flag); } @@ -180,23 +162,13 @@ pub fn get_changes_between_refs( if !result.success { return Ok(vec![]); } - Ok(result - .stdout - .lines() - .filter(|l| !l.is_empty()) - .map(str::to_string) - .collect()) + 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(result - .stdout - .lines() - .filter(|l| !l.is_empty()) - .map(str::to_string) - .collect()) + Ok(process_git_output(&result.stdout)) } /// Stage files. @@ -248,32 +220,175 @@ pub fn parse_remote_branch(branch: &str) -> Option<(String, 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() { + if 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(':') { + if 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 { - // Try to find the default remote - let remotes_output = git_stdout(&["remote"], cwd)?; - let remotes: Vec<&str> = remotes_output.lines().collect(); - - let remote = if remotes.contains(&"upstream") { - "upstream" - } else if remotes.contains(&"origin") { - "origin" - } else if let Some(first) = remotes.first() { - first - } else { - return Ok("origin/master".to_string()); - }; + let remote = get_default_remote(cwd); - // Try to get the default branch from remote - let result = git(&["remote", "show", remote], cwd); - if let Ok(r) = result - && r.success - { - for line in r.stdout.lines() { - let trimmed = line.trim(); - if trimmed.starts_with("HEAD branch:") { - let branch = trimmed.trim_start_matches("HEAD branch:").trim(); - return Ok(format!("{remote}/{branch}")); + // Try to detect HEAD branch from remote + if let Ok(r) = git(&["remote", "show", &remote], cwd) { + if 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}")); + } } } } @@ -286,20 +401,22 @@ pub fn get_default_remote_branch(cwd: &str) -> Result { 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(result - .stdout - .lines() - .filter(|l| !l.is_empty()) - .map(str::to_string) - .collect()) + 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(), remote.to_string()]; + let mut args = vec!["fetch".to_string(), "--".to_string(), remote.to_string()]; if let Some(d) = depth { args.push(format!("--depth={d}")); } @@ -315,3 +432,15 @@ pub fn fetch(remote: &str, branch: &str, cwd: &str, depth: Option) -> Resul } 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/options/get_options.rs b/rust/src/options/get_options.rs index 5cc971a4f..c78accbfc 100644 --- a/rust/src/options/get_options.rs +++ b/rust/src/options/get_options.rs @@ -19,10 +19,8 @@ pub fn get_parsed_options(cwd: &str, cli: CliOptions) -> Result { // If branch doesn't contain '/', resolve the remote if let Some(ref branch) = cli.branch && !branch.contains('/') - && let Ok(default) = commands::get_default_remote_branch(cwd) - && let Some((remote, _)) = commands::parse_remote_branch(&default) { - merged.branch = format!("{remote}/{branch}"); + merged.branch = commands::get_default_remote_branch_for(cwd, branch); } Ok(ParsedOptions { diff --git a/rust/src/options/repo_options.rs b/rust/src/options/repo_options.rs index cb00f4b7e..b72d25ce1 100644 --- a/rust/src/options/repo_options.rs +++ b/rust/src/options/repo_options.rs @@ -1,7 +1,7 @@ use anyhow::Result; use std::path::Path; -use crate::git::commands::{find_git_root, get_default_remote_branch}; +use crate::git::commands::get_default_remote_branch_for; use crate::types::change_info::ChangeType; use crate::types::options::{BeachballOptions, VersionGroupInclude, VersionGroupOptions}; @@ -34,58 +34,40 @@ struct RawVersionGroupOptions { disallowed_change_types: Option>, } -/// Search for and load repo-level beachball config. -/// Searches from `cwd` up to the git root for .beachballrc.json or package.json "beachball" field. -pub fn get_repo_options(cwd: &str, config_path: Option<&str>) -> Result { +/// 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 { - load_json_config(path)? + let resolved = Path::new(project_root).join(path); + load_json_config(resolved.to_str().unwrap_or_default())? } else { - search_for_config(cwd)? + 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, cwd)?; + apply_raw_config(&mut opts, raw, project_root)?; } Ok(opts) } -fn search_for_config(cwd: &str) -> Result> { - let git_root = find_git_root(cwd).unwrap_or_else(|_| cwd.to_string()); - let git_root_path = Path::new(&git_root); - let mut dir = Path::new(cwd).to_path_buf(); - - loop { - // Check for .beachballrc.json - let rc_path = dir.join(".beachballrc.json"); - if rc_path.exists() - && let Ok(config) = load_json_config(rc_path.to_str().unwrap_or_default()) - { - return Ok(config); - } - - // Check for package.json "beachball" field - let pkg_path = dir.join("package.json"); - if pkg_path.exists() - && let Ok(Some(config)) = load_from_package_json(pkg_path.to_str().unwrap_or_default()) - { - return Ok(Some(config)); - } - - // Stop at git root - if dir == git_root_path { - break; - } - if !dir.pop() { - break; - } - } - - Ok(None) -} - fn load_json_config(path: &str) -> Result> { let contents = std::fs::read_to_string(path)?; let config: RawRepoConfig = serde_json::from_str(&contents)?; @@ -108,12 +90,7 @@ fn apply_raw_config(opts: &mut BeachballOptions, raw: RawRepoConfig, cwd: &str) if branch.contains('/') { opts.branch = branch; } else { - let default = get_default_remote_branch(cwd)?; - if let Some((remote, _)) = super::super::git::commands::parse_remote_branch(&default) { - opts.branch = format!("{remote}/{branch}"); - } else { - opts.branch = format!("origin/{branch}"); - } + opts.branch = get_default_remote_branch_for(cwd, &branch); } } diff --git a/rust/tests/git_commands_test.rs b/rust/tests/git_commands_test.rs new file mode 100644 index 000000000..c394e4756 --- /dev/null +++ b/rust/tests/git_commands_test.rs @@ -0,0 +1,399 @@ +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 { + if 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. From 02607ad42a46e1de4dad3540e99139276fd675bf Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Wed, 11 Mar 2026 17:52:22 -0700 Subject: [PATCH 36/38] format --- CLAUDE.md | 2 + rust/tests/git_commands_test.rs | 150 ++++++++++++++++++++++++++++---- 2 files changed, 137 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bae3d3c11..793705585 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -211,6 +211,8 @@ Use syntax and helpers from the newest version of the language where it makes se Where possible, use the LSP instead of grep to understand the code. Also use the LSP to check for errors after making changes. +After making changes, run the commands to build, test, lint, and format. + ### Structure - **Rust**: `src/` with nested modules, integration tests in `tests/` with shared helpers in `tests/common/` diff --git a/rust/tests/git_commands_test.rs b/rust/tests/git_commands_test.rs index c394e4756..42264df4c 100644 --- a/rust/tests/git_commands_test.rs +++ b/rust/tests/git_commands_test.rs @@ -243,9 +243,33 @@ fn test_get_default_remote_prefers_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"]); + 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"); } @@ -257,8 +281,24 @@ fn test_get_default_remote_prefers_origin_over_other() { 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"]); + 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"); } @@ -270,8 +310,24 @@ fn test_get_default_remote_falls_back_to_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"]); + 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"); } @@ -288,8 +344,24 @@ fn test_get_default_remote_matches_repository_string() { })), ); - 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"]); + 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"); } @@ -306,8 +378,24 @@ fn test_get_default_remote_matches_repository_object() { })), ); - 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"]); + 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"); } @@ -324,8 +412,24 @@ fn test_get_default_remote_ssh_remote_format() { })), ); - 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"]); + 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"); } @@ -342,8 +446,24 @@ fn test_get_default_remote_shorthand_format() { })), ); - 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"]); + 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"); } From 736bcd673ff212b34219a35c0825f7ec679b6dc1 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Wed, 11 Mar 2026 19:08:55 -0700 Subject: [PATCH 37/38] skills and formatting --- .claude/shared/rust-and-go.md | 51 +++++++++++++++ .claude/skills/go-impl.md | 11 ++++ .claude/skills/rust-impl.md | 11 ++++ .github/workflows/pr.yml | 16 ++--- .gitignore | 1 + .prettierignore | 6 +- CLAUDE.md | 75 ++-------------------- go/README.md | 88 +++++--------------------- rust/README.md | 68 ++++++-------------- rust/src/git/commands.rs | 47 +++++++------- rust/src/monorepo/package_infos.rs | 13 ++-- rust/src/monorepo/workspace_manager.rs | 36 +++++------ rust/tests/git_commands_test.rs | 10 +-- 13 files changed, 178 insertions(+), 255 deletions(-) create mode 100644 .claude/shared/rust-and-go.md create mode 100644 .claude/skills/go-impl.md create mode 100644 .claude/skills/rust-impl.md 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 e47a2fef4..6f757d119 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -120,14 +120,14 @@ jobs: working-directory: ./rust if: ${{ matrix.os == 'ubuntu-latest' }} - - name: Lint - run: cargo clippy --all-targets -- -D warnings - working-directory: ./rust - - 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 @@ -162,14 +162,14 @@ jobs: working-directory: ./go if: ${{ matrix.os == 'ubuntu-latest' }} - - name: Lint - run: go vet ./... - working-directory: ./go - - 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 3bc75e0dc..e86411a1d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ package-lock.json # ignore when switching between yarn 1/4 branches /.yarn rust/target +go/beachball .claude/settings.local.json docs/.vuepress/.cache diff --git a/.prettierignore b/.prettierignore index b44c7267f..da2a7eb2d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -16,5 +16,7 @@ LICENSE node_modules/ SECURITY.md yarn.lock -rust/ -go/ +rust/** +!rust/README.md +go/** +!go/README.md diff --git a/CLAUDE.md b/CLAUDE.md index 793705585..6820d70cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,15 +87,13 @@ Multi-pass algorithm that calculates version bumps. See comments in file. - 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`. -- `readChangeFiles()` - Loads and validates from disk -- `writeChangeFiles()` - Persists new change files -- `unlinkChangeFiles()` - Deletes after consumption during bump +- 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: `prebump`, `postbump`, `prepublish`, `postpublish`, `precommit` +- 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`. @@ -143,7 +141,7 @@ Multi-pass algorithm that calculates version bumps. See comments in file. **Testing Structure:** -- Fixtures: `__fixtures__` directory +- Fixtures and helpers: `__fixtures__/` directory - Unit tests: `__tests__/` directory - Functional tests: `__functional__/` directory - E2E tests: `__e2e__/` directory @@ -164,69 +162,4 @@ Multi-pass algorithm that calculates version bumps. See comments in file. ## Experimental: Rust and Go Implementations -The `rust/` and `go/` directories contain parallel re-implementations of beachball's `check` and `change` commands and the corresponding tests. - -### Building and Testing - -```bash -# Rust (from rust/ directory) -cargo build -cargo test - -# Go (from go/ directory) -go build ./... -go test ./... -``` - -### Scope - -Both implement: - -- 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. - -Where possible, use the LSP instead of grep to understand the code. Also use the LSP to check for errors after making changes. - -After making changes, run the commands to build, test, lint, and format. - -### Structure - -- **Rust**: `src/` with nested modules, integration tests in `tests/` with shared helpers in `tests/common/` -- **Go**: `cmd/beachball/` CLI entry, `internal/` packages, test helpers in `internal/testutil/`, tests alongside source (`_test.go`) - -### 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. +The `rust/` and `go/` directories contain experimental parallel re-implementations. See the `go-impl` and `rust-impl` custom skills as relevant. diff --git a/go/README.md b/go/README.md index 0ac0c3784..08ae39986 100644 --- a/go/README.md +++ b/go/README.md @@ -2,9 +2,11 @@ A Go re-implementation of beachball's `check` and `change` commands. + + ## Prerequisites -- Go 1.23+ +- Go 1.26+ - `git` on PATH ## Building @@ -68,81 +70,23 @@ Or after building: ## CLI Options -``` -beachball check [flags] -beachball change [flags] - -Flags: - -b, --branch string Target branch (default: origin/master) - --path string Path to the repository - -t, --type string Change type: patch, minor, major, none, etc. - -m, --message string Change description - --all Include all packages - --verbose Verbose output - --config-path string Path to beachball config -``` - -## What's Implemented - -- CLI parsing (cobra) -- JSON config loading (`.beachballrc.json`, `package.json` `"beachball"` field) -- Workspace detection (npm/yarn `workspaces` field) -- `getChangedPackages` (git diff + file-to-package mapping + change file dedup) -- `validate()` (minus `bumpInMemory`/dependency validation) -- Non-interactive `change` command (`--type` + `--message`) -- `check` command - -## What's Not Implemented - -- JS config files (`beachball.config.js`) -- Interactive prompts -- `bumpInMemory` / dependency validation -- `publish`, `bump`, `canary`, `sync` commands -- Changelog generation -- pnpm/rush/lerna workspace detection +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) + main.go # CLI entry point (cobra) internal/ - types/ - change_info.go # ChangeType, ChangeFileInfo, ChangeSet - package_info.go # PackageJson, PackageInfo, PackageGroups - options.go # BeachballOptions, CliOptions - options/ - get_options.go # Option merging - repo_options.go # Config file loading - git/ - commands.go # Git operations (shell out to git) - helpers.go # File/workspace helpers - ensure_shared_history.go # Fetch/deepen for shallow clones - monorepo/ - package_infos.go # Package discovery - scoped_packages.go # Scope filtering - package_groups.go # Version group resolution - filter_ignored.go # Ignore pattern matching - changefile/ - changed_packages.go # Changed package detection - changed_packages_test.go # 11 tests - read_change_files.go # Read change files from disk - write_change_files.go # Write change files - change_types.go # Disallowed type resolution - validation/ - validate.go # Main validation logic - validate_test.go # 3 tests - validators.go # Type/auth validators - are_change_files_deleted.go # Deleted change file detection - commands/ - check.go # Check command - change.go # Change command - change_test.go # 2 tests - logging/ - logging.go # Output helpers - testutil/ - repository.go # Test git repo wrapper - repository_factory.go # Bare repo + clone factory - fixtures.go # Fixture setup helpers - change_files.go # Test change file helpers + 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/rust/README.md b/rust/README.md index 77a865164..00cde6c92 100644 --- a/rust/README.md +++ b/rust/README.md @@ -2,6 +2,8 @@ A Rust re-implementation of beachball's `check` and `change` commands. + + ## Prerequisites - Rust 1.85+ (edition 2024 required by dependencies) @@ -38,6 +40,12 @@ To treat warnings as errors (as CI does): 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 @@ -68,57 +76,23 @@ Or after building: ## CLI Options -``` -beachball check [OPTIONS] -beachball change [OPTIONS] - -Options: - -b, --branch Target branch (default: origin/master) - -p, --path Path to the repository - -t, --type Change type: patch, minor, major, none, etc. - -m, --message Change description - --all Include all packages - --verbose Verbose output - --no-commit Don't commit change files - --no-fetch Don't fetch remote branch -``` - -## What's Implemented - -- CLI parsing (clap) -- JSON config loading (`.beachballrc.json`, `package.json` `"beachball"` field) -- Workspace detection (npm/yarn `workspaces` field) -- `getChangedPackages` (git diff + file-to-package mapping + change file dedup) -- `validate()` (minus `bumpInMemory`/dependency validation) -- Non-interactive `change` command (`--type` + `--message`) -- `check` command - -## What's Not Implemented - -- JS config files (`beachball.config.js`) -- Interactive prompts -- `bumpInMemory` / dependency validation -- `publish`, `bump`, `canary`, `sync` commands -- Changelog generation -- pnpm/rush/lerna workspace detection +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/ # ChangeType, PackageInfo, BeachballOptions - 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 + 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) - changed_packages_test.rs # 11 tests - change_test.rs # 2 tests - validate_test.rs # 3 tests + common/ # Shared test helpers (repository, fixtures) + *_test.rs # Test files ``` diff --git a/rust/src/git/commands.rs b/rust/src/git/commands.rs index 9010971b3..6476f4050 100644 --- a/rust/src/git/commands.rs +++ b/rust/src/git/commands.rs @@ -80,10 +80,10 @@ pub fn search_up(files: &[&str], cwd: &str) -> Option { /// 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) { - if let Some(parent) = found.parent() { - return Ok(parent.to_string_lossy().to_string()); - } + 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) } @@ -304,10 +304,10 @@ pub fn get_default_remote(cwd: &str) -> String { } // 1. Match by repository name from package.json - if !repository_name.is_empty() { - if let Some(matched) = remotes_by_repo_name.get(&repository_name) { - return matched.clone(); - } + 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 @@ -335,13 +335,14 @@ pub fn get_repository_name(raw_url: &str) -> String { } // SSH format: git@github.com:owner/repo.git or user@host:path - if let Some(colon_pos) = raw_url.find(':') { - if 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(); - } + 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(); } } @@ -381,14 +382,14 @@ 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) { - if 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}")); - } + 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}")); } } } diff --git a/rust/src/monorepo/package_infos.rs b/rust/src/monorepo/package_infos.rs index a67d588ba..ad3f07a44 100644 --- a/rust/src/monorepo/package_infos.rs +++ b/rust/src/monorepo/package_infos.rs @@ -28,13 +28,12 @@ pub fn get_package_infos(options: &BeachballOptions) -> Result { // Monorepo: add root package if it exists let root_pkg_path = Path::new(cwd).join("package.json"); - if root_pkg_path.exists() { - if let Ok(info) = read_package_info(&root_pkg_path) { - if !info.name.is_empty() { - let name = info.name.clone(); - infos.insert(name, info); - } - } + 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 { diff --git a/rust/src/monorepo/workspace_manager.rs b/rust/src/monorepo/workspace_manager.rs index 3cef72c93..879a70a51 100644 --- a/rust/src/monorepo/workspace_manager.rs +++ b/rust/src/monorepo/workspace_manager.rs @@ -59,12 +59,11 @@ fn get_npm_yarn_patterns(root: &str) -> Vec { struct ArrayFormat { workspaces: Option>, } - if let Ok(parsed) = serde_json::from_str::(&data) { - if let Some(ws) = parsed.workspaces { - if !ws.is_empty() { - return ws; - } - } + 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/*"]} @@ -76,14 +75,12 @@ fn get_npm_yarn_patterns(root: &str) -> Vec { struct WorkspacesObject { packages: Option>, } - if let Ok(parsed) = serde_json::from_str::(&data) { - if let Some(ws) = parsed.workspaces { - if let Some(pkgs) = ws.packages { - if !pkgs.is_empty() { - return pkgs; - } - } - } + 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![] @@ -116,12 +113,11 @@ fn get_lerna_patterns(root: &str) -> Vec { struct LernaConfig { packages: Option>, } - if let Ok(config) = serde_json::from_str::(&data) { - if let Some(pkgs) = config.packages { - if !pkgs.is_empty() { - return pkgs; - } - } + if let Ok(config) = serde_json::from_str::(&data) + && let Some(pkgs) = config.packages + && !pkgs.is_empty() + { + return pkgs; } } diff --git a/rust/tests/git_commands_test.rs b/rust/tests/git_commands_test.rs index 42264df4c..0576cea3c 100644 --- a/rust/tests/git_commands_test.rs +++ b/rust/tests/git_commands_test.rs @@ -34,11 +34,11 @@ fn write_package_json(dir: &Path, extra_fields: Option) { "name": "test-pkg", "version": "1.0.0" }); - if let Some(extra) = extra_fields { - if 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()); - } + 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(); From 1f4cfe9c9f7e82502c4056a54ae20907fb1aa8b4 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Thu, 12 Mar 2026 01:33:58 -0700 Subject: [PATCH 38/38] go: shared read json --- go/go.mod | 2 +- .../changefile/write_change_files_test.go | 12 +++---- go/internal/commands/change_test.go | 33 +++++-------------- go/internal/jsonutil/read.go | 20 +++++++++++ go/internal/logging/logging.go | 5 +++ go/internal/monorepo/package_infos.go | 26 ++++----------- go/internal/options/repo_options.go | 18 +++------- 7 files changed, 51 insertions(+), 65 deletions(-) create mode 100644 go/internal/jsonutil/read.go diff --git a/go/go.mod b/go/go.mod index b30a4b4ce..70d0c72ec 100644 --- a/go/go.mod +++ b/go/go.mod @@ -7,6 +7,7 @@ require ( 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 ( @@ -14,5 +15,4 @@ require ( 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 - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go/internal/changefile/write_change_files_test.go b/go/internal/changefile/write_change_files_test.go index 4cc04d906..38c388554 100644 --- a/go/internal/changefile/write_change_files_test.go +++ b/go/internal/changefile/write_change_files_test.go @@ -1,12 +1,12 @@ package changefile_test import ( - "encoding/json" "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" @@ -39,10 +39,8 @@ func TestWriteChangeFiles_WritesIndividualChangeFiles(t *testing.T) { foundFoo := false foundBar := false for _, f := range files { - data, err := os.ReadFile(f) - require.NoError(t, err, "failed to read %s", f) - var change types.ChangeFileInfo - require.NoError(t, json.Unmarshal(data, &change), "failed to parse %s", f) + change, err := jsonutil.ReadJSON[types.ChangeFileInfo](f) + require.NoError(t, err, "failed to read/parse %s", f) switch change.PackageName { case "foo": foundFoo = true @@ -157,10 +155,8 @@ func TestWriteChangeFiles_WritesGroupedChangeFiles(t *testing.T) { require.Len(t, files, 1) // Verify it's a grouped format - data, err := os.ReadFile(files[0]) + grouped, err := jsonutil.ReadJSON[types.ChangeInfoMultiple](files[0]) require.NoError(t, err) - var grouped types.ChangeInfoMultiple - require.NoError(t, json.Unmarshal(data, &grouped)) assert.Len(t, grouped.Changes, 2) packageNames := map[string]bool{} diff --git a/go/internal/commands/change_test.go b/go/internal/commands/change_test.go index 5915d45b2..05e5a2b3c 100644 --- a/go/internal/commands/change_test.go +++ b/go/internal/commands/change_test.go @@ -1,12 +1,11 @@ package commands_test import ( - "encoding/json" - "os" "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" @@ -72,12 +71,9 @@ func TestCreatesChangeFileWithTypeAndMessage(t *testing.T) { files := testutil.GetChangeFiles(&parsed.Options) require.Len(t, files, 1) - data, err := os.ReadFile(files[0]) + change, err := jsonutil.ReadJSON[types.ChangeFileInfo](files[0]) require.NoError(t, err) - var change types.ChangeFileInfo - require.NoError(t, json.Unmarshal(data, &change)) - assert.Equal(t, types.ChangeTypePatch, change.Type) assert.Equal(t, "test description", change.Comment) assert.Equal(t, "foo", change.PackageName) @@ -114,9 +110,7 @@ func TestCreatesAndStagesChangeFile(t *testing.T) { files := testutil.GetChangeFiles(&parsed.Options) require.Len(t, files, 1) - data, _ := os.ReadFile(files[0]) - var change types.ChangeFileInfo - json.Unmarshal(data, &change) + 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:") @@ -149,9 +143,7 @@ func TestCreatesAndCommitsChangeFile(t *testing.T) { files := testutil.GetChangeFiles(&parsed.Options) require.Len(t, files, 1) - data, _ := os.ReadFile(files[0]) - var change types.ChangeFileInfo - json.Unmarshal(data, &change) + 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:") } @@ -186,9 +178,7 @@ func TestCreatesAndCommitsChangeFileWithChangeDir(t *testing.T) { // Verify file is in custom directory assert.True(t, strings.Contains(files[0], "changeDir"), "expected file in changeDir, got: %s", files[0]) - data, _ := os.ReadFile(files[0]) - var change types.ChangeFileInfo - json.Unmarshal(data, &change) + 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:") } @@ -219,9 +209,7 @@ func TestCreatesChangeFileWhenNoChangesButPackageProvided(t *testing.T) { files := testutil.GetChangeFiles(&parsed.Options) require.Len(t, files, 1) - data, _ := os.ReadFile(files[0]) - var change types.ChangeFileInfo - json.Unmarshal(data, &change) + change, _ := jsonutil.ReadJSON[types.ChangeFileInfo](files[0]) assert.Equal(t, "foo", change.PackageName) assert.Contains(t, buf.String(), "git staged these change files:") } @@ -255,9 +243,7 @@ func TestCreatesAndCommitsChangeFilesForMultiplePackages(t *testing.T) { packageNames := map[string]bool{} for _, f := range files { - data, _ := os.ReadFile(f) - var change types.ChangeFileInfo - json.Unmarshal(data, &change) + 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) @@ -296,9 +282,8 @@ func TestCreatesAndCommitsGroupedChangeFile(t *testing.T) { files := testutil.GetChangeFiles(&parsed.Options) require.Len(t, files, 1) - data, _ := os.ReadFile(files[0]) - var grouped types.ChangeInfoMultiple - require.NoError(t, json.Unmarshal(data, &grouped)) + grouped, err := jsonutil.ReadJSON[types.ChangeInfoMultiple](files[0]) + require.NoError(t, err) assert.Len(t, grouped.Changes, 2) 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 index e0d11737b..0845488f0 100644 --- a/go/internal/logging/logging.go +++ b/go/internal/logging/logging.go @@ -16,6 +16,11 @@ var ( ) // 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) diff --git a/go/internal/monorepo/package_infos.go b/go/internal/monorepo/package_infos.go index 2e0ad279c..a67730d24 100644 --- a/go/internal/monorepo/package_infos.go +++ b/go/internal/monorepo/package_infos.go @@ -1,13 +1,12 @@ package monorepo import ( - "encoding/json" "fmt" - "os" "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" ) @@ -23,14 +22,10 @@ func GetPackageInfos(options *types.BeachballOptions) (types.PackageInfos, error if len(patterns) == 0 { // Single package repo: read the root package.json directly rootPkgPath := filepath.Join(rootPath, "package.json") - data, err := os.ReadFile(rootPkgPath) + rootPkg, err := jsonutil.ReadJSON[types.PackageJson](rootPkgPath) if err != nil { return nil, err } - var rootPkg types.PackageJson - if err := json.Unmarshal(data, &rootPkg); err != nil { - return nil, err - } info := packageInfoFromJSON(&rootPkg, rootPkgPath) infos[info.Name] = info return infos, nil @@ -38,12 +33,9 @@ func GetPackageInfos(options *types.BeachballOptions) (types.PackageInfos, error // Monorepo: add root package if it exists rootPkgPath := filepath.Join(rootPath, "package.json") - if data, err := os.ReadFile(rootPkgPath); err == nil { - var rootPkg types.PackageJson - if err := json.Unmarshal(data, &rootPkg); err == nil && rootPkg.Name != "" { - rootInfo := packageInfoFromJSON(&rootPkg, rootPkgPath) - infos[rootInfo.Name] = rootInfo - } + if rootPkg, err := jsonutil.ReadJSON[types.PackageJson](rootPkgPath); err == nil && rootPkg.Name != "" { + rootInfo := packageInfoFromJSON(&rootPkg, rootPkgPath) + infos[rootInfo.Name] = rootInfo } if literal { @@ -78,13 +70,9 @@ func GetPackageInfos(options *types.BeachballOptions) (types.PackageInfos, error // 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 { - pkgData, err := os.ReadFile(pkgJsonPath) + pkg, err := jsonutil.ReadJSON[types.PackageJson](pkgJsonPath) if err != nil { - return nil // skip missing files silently - } - var pkg types.PackageJson - if err := json.Unmarshal(pkgData, &pkg); err != nil { - return nil // skip unparseable files silently + return nil // skip missing or unparseable files silently } absMatch, _ := filepath.Abs(pkgJsonPath) info := packageInfoFromJSON(&pkg, absMatch) diff --git a/go/internal/options/repo_options.go b/go/internal/options/repo_options.go index f21650d35..34c44b7b0 100644 --- a/go/internal/options/repo_options.go +++ b/go/internal/options/repo_options.go @@ -1,10 +1,9 @@ package options import ( - "encoding/json" - "os" "path/filepath" + "github.com/microsoft/beachball/internal/jsonutil" "github.com/microsoft/beachball/internal/types" ) @@ -50,26 +49,19 @@ func LoadRepoConfig(projectRoot string, configPath string) (*RepoConfig, error) } func loadConfigFile(path string) (*RepoConfig, error) { - data, err := os.ReadFile(path) + cfg, err := jsonutil.ReadJSON[RepoConfig](path) if err != nil { return nil, err } - var cfg RepoConfig - if err := json.Unmarshal(data, &cfg); err != nil { - return nil, err - } return &cfg, nil } func loadFromPackageJSON(path string) (*RepoConfig, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - var pkg struct { + type pkgWithBeachball struct { Beachball *RepoConfig `json:"beachball"` } - if err := json.Unmarshal(data, &pkg); err != nil { + pkg, err := jsonutil.ReadJSON[pkgWithBeachball](path) + if err != nil { return nil, err } return pkg.Beachball, nil