From bd7446473f19273bc866f04326510b8b8e06cfa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Garc=C3=ADa?= <58498506+negarciacamilo@users.noreply.github.com> Date: Wed, 26 Nov 2025 13:01:43 -0300 Subject: [PATCH] feat: enhance mockery command with git filter options --- CLAUDE.md | 57 +-- cmd/commands/mockery/mockery.go | 24 +- internal/actions/mockery/config.go | 129 +++++ internal/actions/mockery/execution.go | 276 +++++++++++ internal/actions/mockery/git.go | 168 +++++++ internal/actions/mockery/mockery.go | 672 +++----------------------- 6 files changed, 673 insertions(+), 653 deletions(-) create mode 100644 internal/actions/mockery/config.go create mode 100644 internal/actions/mockery/execution.go create mode 100644 internal/actions/mockery/git.go diff --git a/CLAUDE.md b/CLAUDE.md index a2b201f..edad3e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,8 +60,10 @@ draft mockery # Run mockery for all .m draft mockery path/to/.mockery.pkg.yml # Run mockery for specific package configs draft mockery --jobs-num 5 # Run with custom concurrent job limit (default: 5) draft mockery --dry # Dry run - validate configs without executing mockery -draft mockery --git-mod # Run only for packages with modified files (git diff) -draft mockery --git-mod --dry --jobs-num 10 # Combine flags for targeted validation +draft mockery --staged # Run only for packages with staged files (pre-commit) +draft mockery --committed # Run only for packages with committed files (git diff HEAD vs main) +draft mockery --modified # Run for packages with staged OR committed files +draft mockery --staged --dry --jobs-num 10 # Combine flags for targeted validation ``` ### Global Flags @@ -159,37 +161,28 @@ The `draft mockery` command provides concurrent mock generation with configurati - **Package Configs**: Searches for or accepts specific `.mockery.pkg.yml` files in service directories - **Config Merging**: Deep merges base and package configs (package settings take precedence) - **Concurrent Execution**: Runs mockery jobs in parallel with configurable concurrency (`--jobs-num` flag, default: 5) - - Semaphore acquired BEFORE spawning goroutines for proper concurrency control - - Real-time progress updates with spinner showing completed/total packages - **Temporary Files**: Creates temporary merged config files (`.mockery.tmp.*.yml`) that are automatically cleaned up -- **Progress Reporting**: Shows real-time progress with spinner and execution statistics -- **Error Handling**: Continues execution on failures, reports all errors at the end, exits with code 1 if any failed -- **Dry Run Mode** (`--dry`): Validates and prepares all configs without executing mockery commands - - Useful for CI/CD pipelines to verify configuration correctness - - Completes significantly faster than actual execution -- **Git Diff Mode** (`--git-mod`): Only processes packages with modified files - - Compares `HEAD` with `origin/main` (or `origin/master` as fallback) - - Extracts directories from modified files and searches for `.mockery.pkg.yml` in those directories and parents - - Automatically deduplicates found configs - - Cannot be combined with explicit config file paths -- **Graceful Cancellation**: Handles Ctrl+C (SIGINT) and SIGTERM signals - - Stops spawning new goroutines immediately - - Waits for running tasks to complete gracefully - - Cleans up all temporary files via defer - - Displays cancellation summary with completed/cancelled counts - - Context propagated from `main.go` through Cobra commands - -Implementation in `internal/actions/mockery/`: -1. Validates inputs and finds/validates config files (via explicit paths, git diff, or directory walk) -2. Loads base configuration from `.mockery.base.yml` -3. Creates temporary merged configs for each package -4. Executes mockery concurrently with semaphore-based concurrency control - - Monitors context for cancellation signals - - Checks context before spawning each goroutine - - Uses `select` on semaphore acquisition to respect cancellation -5. Cleans up temporary files and displays execution summary (or cancellation summary if interrupted) - -The `new:domain` action uses a simpler mockery integration: it adds packages to `.mockery.yml` and runs `mockery` directly without the config merging system. +- **Dry Run Mode** (`--dry`): Validates configs without executing mockery commands +- **Git Filter Modes**: + - `--staged`: Only processes packages with staged files (pre-commit hooks) + - `--committed`: Only processes packages with committed modifications (compares HEAD with origin/main) + - `--modified`: Processes packages with staged OR committed files + - Cannot combine git filter flags or use with explicit config file paths +- **Graceful Cancellation**: Handles Ctrl+C, waits for running tasks, cleans up temp files + +Implementation organized in `internal/actions/mockery/`: +- `mockery.go`: Main orchestration (Exec, validate, resolveConfigFiles) +- `config.go`: Configuration management (load, merge, temp files, cleanup) +- `git.go`: Git integration (staged/committed/modified file detection) +- `execution.go`: Concurrent execution, progress tracking, result display + +Execution flow: +1. Validate inputs and resolve config files (explicit paths, git diff, or directory walk) +2. Load base config from `.mockery.base.yml` and merge with package configs +3. Create temporary merged configs and execute mockery concurrently +4. Display results and clean up temp files + +The `new:domain` action uses a simpler approach: adds packages to `.mockery.yml` and runs `mockery` directly without config merging. ## Configuration Files diff --git a/cmd/commands/mockery/mockery.go b/cmd/commands/mockery/mockery.go index b9fae41..b8bcd7c 100644 --- a/cmd/commands/mockery/mockery.go +++ b/cmd/commands/mockery/mockery.go @@ -9,9 +9,11 @@ import ( ) var ( - jobsNum int - dry bool - gitMod bool + jobsNum int + dry bool + staged bool + committed bool + modified bool ) var mockeryCmd = &cobra.Command{ @@ -40,21 +42,29 @@ Examples: # Dry run - validate configs without executing mockery draft mockery --dry - # Run mockery only for packages with modified files (compare HEAD with main) - draft mockery --git-mod`, + # Run mockery only for packages with staged files (pre-commit) + draft mockery --staged + + # Run mockery only for packages with committed files (compare HEAD with main) + draft mockery --committed + + # Run mockery for packages with staged OR committed files + draft mockery --modified`, Run: run, } func init() { mockeryCmd.Flags().IntVarP(&jobsNum, "jobs-num", "j", 5, "Number of concurrent mockery jobs to run") mockeryCmd.Flags().BoolVar(&dry, "dry", false, "Dry run - validate and prepare configs without executing mockery") - mockeryCmd.Flags().BoolVar(&gitMod, "git-mod", false, "Only run mockery for packages with modified files (compares HEAD with main branch)") + mockeryCmd.Flags().BoolVar(&staged, "staged", false, "Only run mockery for packages with staged files (pre-commit)") + mockeryCmd.Flags().BoolVar(&committed, "committed", false, "Only run mockery for packages with committed files (compares HEAD with main branch)") + mockeryCmd.Flags().BoolVar(&modified, "modified", false, "Only run mockery for packages with staged OR committed files") } func run(cmd *cobra.Command, args []string) { common.ChDir(cmd) - if err := mockery.New(cmd.Context(), args, jobsNum, dry, gitMod).Exec(); err != nil { + if err := mockery.New(cmd.Context(), args, jobsNum, dry, staged, committed, modified).Exec(); err != nil { log.Exitf(1, "Failed to run mockery: %v", err) } diff --git a/internal/actions/mockery/config.go b/internal/actions/mockery/config.go new file mode 100644 index 0000000..ac68039 --- /dev/null +++ b/internal/actions/mockery/config.go @@ -0,0 +1,129 @@ +package mockery + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" + + "github.com/Drafteame/draft/internal/pkg/files" + "github.com/Drafteame/draft/internal/pkg/log" +) + +func (m *Mockery) loadBaseConfig() (map[string]any, error) { + log.Info("Loading base configuration file...") + if !files.Exists(baseConfigFile) { + return make(map[string]any), nil + } + + data, err := files.Read(baseConfigFile) + if err != nil { + return nil, fmt.Errorf("failed to read base config %s: %w", baseConfigFile, err) + } + + var config map[string]any + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse base config %s: %w (check YAML syntax)", baseConfigFile, err) + } + + return config, nil +} + +func (m *Mockery) deepMerge(base, override map[string]any) map[string]any { + result := make(map[string]any) + + for k, v := range base { + result[k] = v + } + + for k, v := range override { + if vMap, ok := v.(map[string]any); ok { + if baseMap, ok := result[k].(map[string]any); ok { + result[k] = m.deepMerge(baseMap, vMap) + continue + } + } + result[k] = v + } + + return result +} + +func (m *Mockery) createTempConfigs(configFiles []string, baseConfig map[string]any) error { + log.Info("Creating temporary config files...") + + for i, configFile := range configFiles { + var pkgConfig map[string]any + + if err := files.LoadYAML(configFile, &pkgConfig); err != nil { + return fmt.Errorf("failed to load %s: %w", configFile, err) + } + + merged := m.deepMerge(baseConfig, pkgConfig) + + tmpFile, err := m.generateTempFileName(configFile, i) + if err != nil { + return fmt.Errorf("failed to generate temp file name: %w", err) + } + + mergedData, err := yaml.Marshal(merged) + if err != nil { + return fmt.Errorf("failed to marshal merged config for %s: %w", configFile, err) + } + + if err := files.Create(tmpFile, mergedData); err != nil { + return fmt.Errorf("failed to write temp config %s: %w", tmpFile, err) + } + + m.mu.Lock() + m.tmpFiles = append(m.tmpFiles, tmpFile) + m.mu.Unlock() + } + + return nil +} + +func (m *Mockery) generateTempFileName(configFile string, index int) (string, error) { + randID, err := generateRandomID() + if err != nil { + return "", err + } + + dir := filepath.Dir(configFile) + pkgName := filepath.Base(dir) + if pkgName == "." { + pkgName = "root" + } + + tmpFile := fmt.Sprintf("%s%s.%d.%s%s", tmpConfigPrefix, pkgName, index, randID, tmpConfigSuffix) + + return tmpFile, nil +} + +func (m *Mockery) cleanup() { + if len(m.tmpFiles) == 0 { + return + } + + var failed int + for _, tmpFile := range m.tmpFiles { + if err := os.Remove(tmpFile); err != nil && !os.IsNotExist(err) { + failed++ + } + } + + if failed > 0 { + log.Warnf("Failed to clean up %d temporary file(s)", failed) + } +} + +func generateRandomID() (string, error) { + bytes := make([]byte, 4) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("failed to generate random ID: %w", err) + } + return hex.EncodeToString(bytes), nil +} diff --git a/internal/actions/mockery/execution.go b/internal/actions/mockery/execution.go new file mode 100644 index 0000000..a1bd6a4 --- /dev/null +++ b/internal/actions/mockery/execution.go @@ -0,0 +1,276 @@ +package mockery + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/charmbracelet/huh/spinner" + + "github.com/Drafteame/draft/internal/pkg/exec" + "github.com/Drafteame/draft/internal/pkg/log" +) + +type mockeryJob struct { + configFile string + tmpFile string + err error + duration time.Duration +} + +type executionStats struct { + total int + succeeded int + failed int + duration time.Duration +} + +type progressUpdate struct { + current int + total int + configFile string + success bool + err error + duration time.Duration +} + +func (m *Mockery) executeConcurrent(configFiles []string) []mockeryJob { + log.Info("Executing mockery commands...") + + var ( + wg sync.WaitGroup + results = make([]mockeryJob, 0, len(configFiles)) + resultsChan = make(chan mockeryJob, len(configFiles)) + semaphore = make(chan struct{}, m.jobsNum) + progressChan = make(chan progressUpdate, len(configFiles)) + completed = 0 + execErr error + doneChan = make(chan struct{}) + ) + + total := len(configFiles) + spin := spinner.New().Title(fmt.Sprintf("[0 / %d] Preparing...", total)) + + action := func() { + defer close(doneChan) + + var progressWg sync.WaitGroup + var cancelled bool + progressWg.Add(1) + + go func() { + defer progressWg.Done() + for update := range progressChan { + completed++ + status := "✓" + if !update.success { + status = "✗" + } + + shortName := m.shortenConfigPath(update.configFile) + spin.Title(fmt.Sprintf("[%s] [%2d / %d] %s (%.2fs)", + status, completed, total, shortName, update.duration.Seconds())) + } + }() + + for idx := range m.tmpFiles { + if m.ctx.Err() != nil { + if !cancelled { + log.Warn("Operation cancelled by user, waiting for ongoing tasks to complete...") + cancelled = true + execErr = m.ctx.Err() + } + goto waitForCompletion + } + + select { + case semaphore <- struct{}{}: + case <-m.ctx.Done(): + if !cancelled { + log.Warn("Operation cancelled by user, waiting for ongoing tasks to complete...") + cancelled = true + execErr = m.ctx.Err() + } + goto waitForCompletion + } + + wg.Add(1) + go m.executeJob( + idx, + resultsChan, + progressChan, + &wg, + semaphore, + configFiles[idx], + m.tmpFiles[idx], + total, + ) + } + + waitForCompletion: + wg.Wait() + close(progressChan) + close(resultsChan) + progressWg.Wait() + } + + if err := spin.Action(action).Run(); err != nil { + execErr = fmt.Errorf("execution error: %w", err) + } + + <-doneChan + + if execErr != nil && !errors.Is(execErr, context.Canceled) { + log.Errorf("Execution encountered errors: %v", execErr) + } + + for result := range resultsChan { + results = append(results, result) + } + + return results +} + +func (m *Mockery) executeJob( + idx int, + resultChan chan mockeryJob, + progressChan chan progressUpdate, + wg *sync.WaitGroup, + sem chan struct{}, + configFile string, + tmpFile string, + total int, +) { + defer wg.Done() + defer func() { <-sem }() + + select { + case <-m.ctx.Done(): + result := mockeryJob{ + configFile: configFile, + tmpFile: tmpFile, + err: m.ctx.Err(), + duration: 0, + } + resultChan <- result + return + default: + } + + startTime := time.Now() + err := m.runMockery(tmpFile, configFile) + duration := time.Since(startTime) + + result := mockeryJob{ + configFile: configFile, + tmpFile: tmpFile, + err: err, + duration: duration, + } + + resultChan <- result + + progressChan <- progressUpdate{ + current: idx + 1, + total: total, + configFile: configFile, + success: err == nil, + err: err, + duration: duration, + } +} + +func (m *Mockery) runMockery(configPath, originalPath string) error { + if m.dry { + log.Debugf("Dry run: would execute mockery --config %s", configPath) + return nil + } + + command := fmt.Sprintf("mockery --config %s", configPath) + output, err := exec.Command(command) + if err != nil { + return fmt.Errorf("mockery failed for %s: %w\nOutput: %s\nTip: Check the config syntax and package paths", originalPath, err, output) + } + return nil +} + +func (m *Mockery) shortenConfigPath(configPath string) string { + path := strings.TrimSuffix(configPath, "/.mockery.pkg.yml") + + parts := strings.Split(path, "/") + if len(parts) > 3 { + return ".../" + strings.Join(parts[len(parts)-3:], "/") + } + + return path +} + +func (m *Mockery) calculateStats(results []mockeryJob, startTime time.Time) executionStats { + stats := executionStats{ + total: len(m.tmpFiles), + duration: time.Since(startTime), + } + + for _, result := range results { + if errors.Is(result.err, context.Canceled) || errors.Is(result.err, context.DeadlineExceeded) { + continue + } + + if result.err != nil { + stats.failed++ + } else { + stats.succeeded++ + } + } + + return stats +} + +func (m *Mockery) displaySummary(stats executionStats, results []mockeryJob) { + if stats.failed > 0 { + log.Errorf("✗ Failed: %d/%d packages (%.2fs)", stats.failed, stats.total, stats.duration.Seconds()) + log.Errorf("Failed packages:") + + for _, result := range results { + if result.err != nil { + log.Errorf(" • %s", result.configFile) + log.Errorf(" %v", result.err) + } + } + + log.Info("Tip: Check the error messages above for details on how to fix the configurations") + } else { + if m.dry { + log.Successf("✓ All %d package(s) validated successfully (%.2fs)", stats.total, stats.duration.Seconds()) + log.Info("Dry run completed - no mockery commands were executed") + } else { + log.Successf("✓ All %d package(s) completed successfully (%.2fs)", stats.total, stats.duration.Seconds()) + } + } +} + +func (m *Mockery) displayCancellationSummary(stats executionStats, results []mockeryJob) { + completed := stats.succeeded + stats.failed + cancelled := stats.total - completed + + log.Warnf("⚠ Operation cancelled by user") + log.Infof("Completed: %d/%d packages", completed, stats.total) + log.Infof("Cancelled: %d packages", cancelled) + log.Infof("Duration: %.2fs", stats.duration.Seconds()) + + if stats.failed > 0 { + log.Warnf("Failed packages before cancellation:") + + for _, result := range results { + if result.err != nil && !errors.Is(result.err, context.Canceled) && !errors.Is(result.err, context.DeadlineExceeded) { + log.Errorf(" • %s", result.configFile) + log.Errorf(" %v", result.err) + } + } + } + + log.Info("Temporary files have been cleaned up") +} diff --git a/internal/actions/mockery/git.go b/internal/actions/mockery/git.go new file mode 100644 index 0000000..da9537c --- /dev/null +++ b/internal/actions/mockery/git.go @@ -0,0 +1,168 @@ +package mockery + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/Drafteame/draft/internal/pkg/exec" + "github.com/Drafteame/draft/internal/pkg/files" + "github.com/Drafteame/draft/internal/pkg/log" +) + +func (m *Mockery) findConfigsFromStaged() ([]string, error) { + return m.findConfigsFromGitSource("staged", m.getStagedFiles) +} + +func (m *Mockery) findConfigsFromCommitted() ([]string, error) { + return m.findConfigsFromGitSource("committed", m.getCommittedFiles) +} + +func (m *Mockery) findConfigsFromModified() ([]string, error) { + stagedFiles, err := m.getStagedFiles() + if err != nil { + return nil, fmt.Errorf("failed to get staged files: %w", err) + } + + committedFiles, err := m.getCommittedFiles() + if err != nil { + return nil, fmt.Errorf("failed to get committed files: %w", err) + } + + allFiles := append(stagedFiles, committedFiles...) + + if len(allFiles) == 0 { + log.Info("No staged or committed files found") + return nil, nil + } + + configs := m.findConfigsFromFiles(allFiles) + + if len(configs) == 0 { + log.Warn("No .mockery.pkg.yml files found in modified directories") + } + + return configs, nil +} + +func (m *Mockery) findConfigsFromGitSource(name string, getFiles func() ([]string, error)) ([]string, error) { + files, err := getFiles() + if err != nil { + return nil, fmt.Errorf("failed to get %s files: %w", name, err) + } + + if len(files) == 0 { + log.Infof("No %s files found", name) + return nil, nil + } + + configs := m.findConfigsFromFiles(files) + + if len(configs) == 0 { + log.Warnf("No .mockery.pkg.yml files found in %s directories", name) + } + + return configs, nil +} + +func (m *Mockery) getStagedFiles() ([]string, error) { + cmd := "git diff --cached --name-only --diff-filter=AM" + output, err := exec.Command(cmd) + if err != nil { + return nil, fmt.Errorf("failed to run git diff --cached: %w\nOutput: %s\nTip: Ensure you're in a git repository", err, output) + } + + return parseGitOutput(output), nil +} + +func (m *Mockery) getCommittedFiles() ([]string, error) { + mainBranch := m.detectMainBranch() + + cmd := fmt.Sprintf("git diff --name-only --diff-filter=AM origin/%s...HEAD", mainBranch) + output, err := exec.Command(cmd) + if err != nil { + return nil, fmt.Errorf("failed to run git diff: %w\nOutput: %s\nTip: Ensure you're in a git repository and origin/%s exists", err, output, mainBranch) + } + + return parseGitOutput(output), nil +} + +func (m *Mockery) detectMainBranch() string { + if _, err := exec.Command("git rev-parse --verify origin/main"); err == nil { + return "main" + } + + if _, err := exec.Command("git rev-parse --verify origin/master"); err == nil { + return "master" + } + + return "main" +} + +func parseGitOutput(output string) []string { + lines := strings.Split(strings.TrimSpace(output), "\n") + var result []string + + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + result = append(result, line) + } + } + + return result +} + +func (m *Mockery) findConfigsFromFiles(filePaths []string) []string { + directories := m.extractDirectories(filePaths) + return m.searchConfigsInDirs(directories) +} + +func (m *Mockery) extractDirectories(filePaths []string) []string { + dirMap := make(map[string]struct{}) + + for _, file := range filePaths { + dir := filepath.Dir(file) + if dir != "" && dir != "." { + dirMap[dir] = struct{}{} + } + } + + var directories []string + for dir := range dirMap { + directories = append(directories, dir) + } + + return directories +} + +func (m *Mockery) searchConfigsInDirs(directories []string) []string { + configMap := make(map[string]struct{}) + + for _, dir := range directories { + currentDir := dir + for { + configPath := filepath.Join(currentDir, pkgConfigSuffix) + if files.Exists(configPath) { + normalized, err := filepath.Abs(configPath) + if err != nil { + normalized = configPath + } + configMap[normalized] = struct{}{} + } + + parent := filepath.Dir(currentDir) + if parent == currentDir || parent == "." || parent == "/" { + break + } + currentDir = parent + } + } + + var configs []string + for config := range configMap { + configs = append(configs, config) + } + + return configs +} diff --git a/internal/actions/mockery/mockery.go b/internal/actions/mockery/mockery.go index 88d6e24..bc5bc2d 100644 --- a/internal/actions/mockery/mockery.go +++ b/internal/actions/mockery/mockery.go @@ -2,9 +2,6 @@ package mockery import ( "context" - "crypto/rand" - "encoding/hex" - "errors" "fmt" "os" "path/filepath" @@ -12,11 +9,7 @@ import ( "sync" "time" - "github.com/charmbracelet/huh/spinner" - "gopkg.in/yaml.v3" - "github.com/Drafteame/draft/internal/pkg/dirs" - "github.com/Drafteame/draft/internal/pkg/exec" "github.com/Drafteame/draft/internal/pkg/files" "github.com/Drafteame/draft/internal/pkg/log" ) @@ -28,55 +21,34 @@ const ( tmpConfigSuffix = ".yml" ) -type ( - Mockery struct { - ctx context.Context - configFiles []string - jobsNum int - dry bool - gitMod bool - tmpFiles []string // Track for cleanup - mu sync.Mutex - } - - mockeryJob struct { - configFile string - tmpFile string - err error - duration time.Duration - } - - executionStats struct { - total int - succeeded int - failed int - duration time.Duration - } - - progressUpdate struct { - current int - total int - configFile string - success bool - err error - duration time.Duration - } -) +type Mockery struct { + ctx context.Context + configFiles []string + jobsNum int + dry bool + staged bool + committed bool + modified bool + tmpFiles []string + mu sync.Mutex +} -func New(ctx context.Context, configFiles []string, jobsNum int, dry bool, gitMod bool) *Mockery { +func New(ctx context.Context, configFiles []string, jobsNum int, dry bool, staged bool, committed bool, modified bool) *Mockery { return &Mockery{ ctx: ctx, configFiles: configFiles, jobsNum: jobsNum, dry: dry, - gitMod: gitMod, + staged: staged, + committed: committed, + modified: modified, tmpFiles: make([]string, 0), } } func (m *Mockery) Exec() error { log.Info("Running mockery...") - // Validate inputs + if err := m.validate(); err != nil { return err } @@ -87,7 +59,6 @@ func (m *Mockery) Exec() error { startTime := time.Now() - // Find and validate config files configFiles, err := m.resolveConfigFiles() if err != nil { return err @@ -99,36 +70,29 @@ func (m *Mockery) Exec() error { return nil } - // Load base config baseConfig, err := m.loadBaseConfig() if err != nil { return err } - // Create temporary config files - if errTmp := m.createTempConfigs(configFiles, baseConfig); errTmp != nil { - m.cleanup() // Clean up any temp files created before the error - return errTmp + if err := m.createTempConfigs(configFiles, baseConfig); err != nil { + m.cleanup() + return err } - // Ensure cleanup on exit defer m.cleanup() - // Execute mockery concurrently with the progress spinner - results := m.executeConcurrentWithProgress(configFiles) + results := m.executeConcurrent(configFiles) - // Check if context was canceled if m.ctx.Err() != nil { stats := m.calculateStats(results, startTime) m.displayCancellationSummary(stats, results) return fmt.Errorf("operation cancelled: %w", m.ctx.Err()) } - // Calculate and display stats stats := m.calculateStats(results, startTime) m.displaySummary(stats, results) - // Return error if any executions failed if stats.failed > 0 { return fmt.Errorf("mockery execution failed for %d package(s)", stats.failed) } @@ -136,7 +100,6 @@ func (m *Mockery) Exec() error { return nil } -// validate validates input parameters func (m *Mockery) validate() error { if m.jobsNum <= 0 { return fmt.Errorf("invalid --jobs-num value: %d (must be greater than 0)", m.jobsNum) @@ -146,58 +109,61 @@ func (m *Mockery) validate() error { log.Warnf("Very high concurrency (%d) may cause performance issues", m.jobsNum) } - if m.gitMod && len(m.configFiles) > 0 { - return fmt.Errorf("cannot use --git-mod with explicit config file paths") + gitFlagsCount := 0 + if m.staged { + gitFlagsCount++ + } + if m.committed { + gitFlagsCount++ + } + if m.modified { + gitFlagsCount++ + } + + if gitFlagsCount > 1 { + return fmt.Errorf("cannot combine --staged, --committed, and --modified flags (use --modified for staged + committed)") + } + + if gitFlagsCount > 0 && len(m.configFiles) > 0 { + return fmt.Errorf("cannot use --staged, --committed, or --modified with explicit config file paths") } return nil } -// resolveConfigFiles resolves, validates, and deduplicates config files func (m *Mockery) resolveConfigFiles() ([]string, error) { log.Info("Resolving config files...") - var configFiles []string - // If git-mod is enabled, find configs from modified files - if m.gitMod { - found, err := m.findConfigsFromGitDiff() - if err != nil { - return nil, err - } - configFiles = found + var configFiles []string + var err error + + if m.staged { + configFiles, err = m.findConfigsFromStaged() + } else if m.committed { + configFiles, err = m.findConfigsFromCommitted() + } else if m.modified { + configFiles, err = m.findConfigsFromModified() } else if len(m.configFiles) > 0 { - // If files provided, validate them - validated, err := m.validateProvidedConfigs(m.configFiles) - if err != nil { - return nil, err - } - configFiles = validated + configFiles, err = m.validateProvidedConfigs(m.configFiles) } else { - // Search for config files - found, err := m.findPackageConfigs() - if err != nil { - return nil, err - } - configFiles = found + configFiles, err = m.findPackageConfigs() } - // Deduplicate - configFiles = m.deduplicateFiles(configFiles) + if err != nil { + return nil, err + } - return configFiles, nil + return m.deduplicateFiles(configFiles), nil } -// validateProvidedConfigs validates user-provided config file paths func (m *Mockery) validateProvidedConfigs(paths []string) ([]string, error) { var validated []string for _, path := range paths { - // Check if a file exists if !files.Exists(path) { return nil, fmt.Errorf("config file not found: %s", path) } - // Check if it's a file stat, err := os.Stat(path) if err != nil { return nil, fmt.Errorf("failed to stat %s: %w", path, err) @@ -207,7 +173,6 @@ func (m *Mockery) validateProvidedConfigs(paths []string) ([]string, error) { return nil, fmt.Errorf("path is a directory, not a file: %s", path) } - // Warn if it doesn't follow naming convention if !strings.HasSuffix(path, pkgConfigSuffix) { log.Warnf("Config file %s doesn't follow naming convention (.mockery.pkg.yml)", path) } @@ -218,13 +183,11 @@ func (m *Mockery) validateProvidedConfigs(paths []string) ([]string, error) { return validated, nil } -// deduplicateFiles removes duplicate file paths -func (m *Mockery) deduplicateFiles(files []string) []string { +func (m *Mockery) deduplicateFiles(fileList []string) []string { seen := make(map[string]bool) var result []string - for _, file := range files { - // Normalize a path + for _, file := range fileList { normalized, err := filepath.Abs(file) if err != nil { normalized = file @@ -236,25 +199,22 @@ func (m *Mockery) deduplicateFiles(files []string) []string { } } - if len(result) < len(files) { - log.Warnf("Removed %d duplicate config file(s)", len(files)-len(result)) + if len(result) < len(fileList) { + log.Warnf("Removed %d duplicate config file(s)", len(fileList)-len(result)) } return result } -// findPackageConfigs searches for all .mockery.pkg.yml files in the project func (m *Mockery) findPackageConfigs() ([]string, error) { var configs []string - searchErr := dirs.Walk(".", func(path string, info os.FileInfo, err error) error { + err := dirs.Walk(".", func(path string, info os.FileInfo, err error) error { if err != nil { return err } - // Skip hidden directories (but not the root "." directory) if info.IsDir() { - // Don't skip the root directory if path == "." { return nil } @@ -272,525 +232,9 @@ func (m *Mockery) findPackageConfigs() ([]string, error) { return nil }) - if searchErr != nil { - return nil, fmt.Errorf("failed to walk directory: %w", searchErr) - } - - return configs, nil -} - -// findConfigsFromGitDiff finds .mockery.pkg.yml files in directories with modified files -func (m *Mockery) findConfigsFromGitDiff() ([]string, error) { - // Get modified files comparing HEAD with the main branch - modifiedFiles, err := m.getModifiedFiles() if err != nil { - return nil, fmt.Errorf("failed to get modified files: %w", err) - } - - if len(modifiedFiles) == 0 { - log.Info("No modified files found in git diff") - return nil, nil - } - - // Extract and deduplicate base directories - directories := m.extractDirectories(modifiedFiles) - - // Search for .mockery.pkg.yml in each directory and its parents - configs := m.findConfigsInDirectories(directories) - - if len(configs) == 0 { - log.Warn("No .mockery.pkg.yml files found in modified directories") + return nil, fmt.Errorf("failed to walk directory: %w", err) } return configs, nil } - -// getModifiedFiles returns a list of modified files using git diff -func (m *Mockery) getModifiedFiles() ([]string, error) { - // Try to get the main branch name - mainBranch := "main" - - // Check if origin/main exists - checkCmd := "git rev-parse --verify origin/main" - if _, err := exec.Command(checkCmd); err != nil { - // Try master as fallback - checkCmd = "git rev-parse --verify origin/master" - if _, err := exec.Command(checkCmd); err == nil { - mainBranch = "master" - } - } - - // Get modified and new files - cmd := fmt.Sprintf("git diff --name-only --diff-filter=AM origin/%s...HEAD", mainBranch) - output, err := exec.Command(cmd) - if err != nil { - return nil, fmt.Errorf("failed to run git diff: %w\nOutput: %s\nTip: Ensure you're in a git repository and origin/%s exists", err, output, mainBranch) - } - - // Parse output into a file list - fileList := strings.Split(strings.TrimSpace(output), "\n") - var result []string - for _, file := range fileList { - file = strings.TrimSpace(file) - if file != "" { - result = append(result, file) - } - } - - return result, nil -} - -// extractDirectories extracts unique directories from file paths -func (m *Mockery) extractDirectories(files []string) []string { - dirMap := make(map[string]struct{}) - - for _, file := range files { - // Get the directory of the file - dir := filepath.Dir(file) - if dir != "" && dir != "." { - dirMap[dir] = struct{}{} - } - } - - // Convert map to slice - var directories []string - for dir := range dirMap { - directories = append(directories, dir) - } - - return directories -} - -// findConfigsInDirectories searches for .mockery.pkg.yml files in directories and their parents -func (m *Mockery) findConfigsInDirectories(directories []string) []string { - configMap := make(map[string]struct{}) - - for _, dir := range directories { - // Check the current directory and walk up to find .mockery.pkg.yml - currentDir := dir - for { - configPath := filepath.Join(currentDir, pkgConfigSuffix) - if files.Exists(configPath) { - // Normalize a path - normalized, err := filepath.Abs(configPath) - if err != nil { - normalized = configPath - } - configMap[normalized] = struct{}{} - } - - // Move to the parent directory - parent := filepath.Dir(currentDir) - if parent == currentDir || parent == "." || parent == "/" { - break - } - currentDir = parent - } - } - - // Convert map to slice - var configs []string - for config := range configMap { - configs = append(configs, config) - } - - return configs -} - -// loadBaseConfig loads the base configuration file -func (m *Mockery) loadBaseConfig() (map[string]any, error) { - log.Info("Loading base configuration file...") - if !files.Exists(baseConfigFile) { - return make(map[string]any), nil - } - - data, err := files.Read(baseConfigFile) - if err != nil { - return nil, fmt.Errorf("failed to read base config %s: %w", baseConfigFile, err) - } - - var config map[string]any - if err := yaml.Unmarshal(data, &config); err != nil { - return nil, fmt.Errorf("failed to parse base config %s: %w (check YAML syntax)", baseConfigFile, err) - } - - return config, nil -} - -// deepMerge performs a deep merge of two maps, with b taking precedence -func (m *Mockery) deepMerge(a, b map[string]any) map[string]any { - result := make(map[string]any) - - // Copy all from a - for k, v := range a { - result[k] = v - } - - // Merge from b - for k, v := range b { - if vMap, ok := v.(map[string]any); ok { - // If b[k] is a map, try to deep merge with a[k] - if aMap, ok := result[k].(map[string]any); ok { - result[k] = m.deepMerge(aMap, vMap) - continue - } - } - // Otherwise, b[k] overwrites result[k] - result[k] = v - } - - return result -} - -// createTempConfigs creates temporary config files for each package -func (m *Mockery) createTempConfigs(configFiles []string, baseConfig map[string]any) error { - log.Info("Creating temporary config files...") - for i, configFile := range configFiles { - var pkgConfig map[string]any - - if errLoad := files.LoadYAML(configFile, &pkgConfig); errLoad != nil { - return fmt.Errorf("failed to load %s: %w", configFile, errLoad) - } - - // Deep merge configs (package takes precedence) - merged := m.deepMerge(baseConfig, pkgConfig) - - // Generate temp file name with context - tmpFile, err := m.generateTempFileName(configFile, i) - if err != nil { - return fmt.Errorf("failed to generate temp file name: %w", err) - } - - // Marshal merged config - mergedData, err := yaml.Marshal(merged) - if err != nil { - return fmt.Errorf("failed to marshal merged config for %s: %w", configFile, err) - } - - // Write the temp file - if errCreate := files.Create(tmpFile, mergedData); errCreate != nil { - return fmt.Errorf("failed to write temp config %s: %w", tmpFile, errCreate) - } - - // Track for cleanup - m.mu.Lock() - m.tmpFiles = append(m.tmpFiles, tmpFile) - m.mu.Unlock() - } - - return nil -} - -// generateTempFileName generates a descriptive temp file name -func (m *Mockery) generateTempFileName(configFile string, index int) (string, error) { - randID, err := generateRandomID() - if err != nil { - return "", err - } - - // Extract a meaningful name from the config file path - dir := filepath.Dir(configFile) - pkgName := filepath.Base(dir) - if pkgName == "." { - pkgName = "root" - } - - // Create a descriptive temp file name: .mockery.tmp.[pkg-name].[idx].[rand].yml - tmpFile := fmt.Sprintf("%s%s.%d.%s%s", tmpConfigPrefix, pkgName, index, randID, tmpConfigSuffix) - - return tmpFile, nil -} - -// executeConcurrentWithProgress executes mockery commands concurrently with progress spinner -func (m *Mockery) executeConcurrentWithProgress(configFiles []string) []mockeryJob { - log.Info("Executing mockery commands...") - var ( - wg sync.WaitGroup - results = make([]mockeryJob, 0, len(configFiles)) - resultsChan = make(chan mockeryJob, len(configFiles)) - semaphore = make(chan struct{}, m.jobsNum) - progressChan = make(chan progressUpdate, len(configFiles)) - completed = 0 - execErr error - doneChan = make(chan struct{}) - ) - - total := len(configFiles) - - spin := spinner.New().Title(fmt.Sprintf("[0 / %d] Preparing...", total)) - - action := func() { - defer close(doneChan) - - var progressWg sync.WaitGroup - var cancelled bool - progressWg.Add(1) - - // Start a progress reader goroutine before spawning work goroutines - go func() { - defer progressWg.Done() - // Process progress updates as they arrive - for update := range progressChan { - completed++ - status := "✓" - if !update.success { - status = "✗" - } - - // Extract a shorter name from the config file path - shortName := m.shortenConfigPath(update.configFile) - - spin.Title(fmt.Sprintf("[%s] [%2d / %d] %s (%.2fs)", - status, completed, total, shortName, update.duration.Seconds())) - } - }() - - // Start goroutines to process configs - for idx := range m.tmpFiles { - // Check if context is cancelled before starting new goroutine - if m.ctx.Err() != nil { - if !cancelled { - log.Warn("Operation cancelled by user, waiting for ongoing tasks to complete...") - cancelled = true - execErr = m.ctx.Err() - } - goto waitForCompletion - } - - // Acquire semaphore before spawning a goroutine - select { - case semaphore <- struct{}{}: - // Successfully acquired semaphore - case <-m.ctx.Done(): - // Context cancelled while waiting for semaphore - if !cancelled { - log.Warn("Operation cancelled by user, waiting for ongoing tasks to complete...") - cancelled = true - execErr = m.ctx.Err() - } - goto waitForCompletion - } - - wg.Add(1) - go m.execute( - idx, - resultsChan, - progressChan, - &wg, - semaphore, - configFiles[idx], - m.tmpFiles[idx], - total, - ) - } - - waitForCompletion: - // Wait for all work goroutines to complete - wg.Wait() - close(progressChan) - close(resultsChan) - - // Wait for the progress reader to finish processing all updates - progressWg.Wait() - } - - if err := spin.Action(action).Run(); err != nil { - execErr = fmt.Errorf("execution error: %w", err) - } - - // Wait for action to complete - <-doneChan - - if execErr != nil && !errors.Is(execErr, context.Canceled) { - log.Errorf("Execution encountered errors: %v", execErr) - } - - for result := range resultsChan { - results = append(results, result) - } - - return results -} - -// execute runs a mockery command for a specific configuration file and communicates results and progress updates. -func (m *Mockery) execute( - idx int, - resultChan chan mockeryJob, - progressChan chan progressUpdate, - wg *sync.WaitGroup, - sem chan struct{}, - configFile string, - tmpFile string, - total int, -) { - defer wg.Done() - defer func() { <-sem }() // Release semaphore after work - - // Check if context is cancelled before starting work - select { - case <-m.ctx.Done(): - // Context cancelled, skip execution - result := mockeryJob{ - configFile: configFile, - tmpFile: tmpFile, - err: m.ctx.Err(), - duration: 0, - } - resultChan <- result - return - default: - // Continue with execution - } - - startTime := time.Now() - err := m.runMockery(tmpFile, configFile) - duration := time.Since(startTime) - - result := mockeryJob{ - configFile: configFile, - tmpFile: tmpFile, - err: err, - duration: duration, - } - - resultChan <- result - - // Send progress update - progressChan <- progressUpdate{ - current: idx + 1, - total: total, - configFile: configFile, - success: err == nil, - err: err, - duration: duration, - } -} - -// shortenConfigPath extracts a meaningful short name from a config file path -func (m *Mockery) shortenConfigPath(configPath string) string { - // Remove .mockery.pkg.yml suffix - path := strings.TrimSuffix(configPath, "/.mockery.pkg.yml") - - // If a path is too long, show only the last 2-3 segments - parts := strings.Split(path, "/") - if len(parts) > 3 { - return ".../" + strings.Join(parts[len(parts)-3:], "/") - } - - return path -} - -// runMockery executes mockery with the given config file -func (m *Mockery) runMockery(configPath, originalPath string) error { - if m.dry { - // Dry run - skip execution and just validate the config file exists - log.Debugf("Dry run: would execute mockery --config %s", configPath) - return nil - } - - command := fmt.Sprintf("mockery --config %s", configPath) - output, err := exec.Command(command) - if err != nil { - // Provide more context in error - return fmt.Errorf("mockery failed for %s: %w\nOutput: %s\nTip: Check the config syntax and package paths", originalPath, err, output) - } - return nil -} - -// cleanup removes all temporary config files -func (m *Mockery) cleanup() { - if len(m.tmpFiles) == 0 { - return - } - - var failed int - for _, tmpFile := range m.tmpFiles { - if err := os.Remove(tmpFile); err != nil && !os.IsNotExist(err) { - failed++ - } - } - - if failed > 0 { - log.Warnf("Failed to clean up %d temporary file(s)", failed) - } -} - -// calculateStats calculates execution statistics -func (m *Mockery) calculateStats(results []mockeryJob, startTime time.Time) executionStats { - stats := executionStats{ - total: len(m.tmpFiles), // Use total tmpFiles, not just results length - duration: time.Since(startTime), - } - - for _, result := range results { - // Don't count context cancellation as failure - if errors.Is(result.err, context.Canceled) || errors.Is(result.err, context.DeadlineExceeded) { - continue - } - - if result.err != nil { - stats.failed++ - } else { - stats.succeeded++ - } - } - - return stats -} - -// displaySummary displays execution summary -func (m *Mockery) displaySummary(stats executionStats, results []mockeryJob) { - if stats.failed > 0 { - log.Errorf("✗ Failed: %d/%d packages (%.2fs)", stats.failed, stats.total, stats.duration.Seconds()) - log.Errorf("Failed packages:") - - for _, result := range results { - if result.err != nil { - log.Errorf(" • %s", result.configFile) - log.Errorf(" %v", result.err) - } - } - - log.Info("Tip: Check the error messages above for details on how to fix the configurations") - } else { - if m.dry { - log.Successf("✓ All %d package(s) validated successfully (%.2fs)", stats.total, stats.duration.Seconds()) - log.Info("Dry run completed - no mockery commands were executed") - } else { - log.Successf("✓ All %d package(s) completed successfully (%.2fs)", stats.total, stats.duration.Seconds()) - } - } -} - -// displayCancellationSummary displays summary when operation is cancelled -func (m *Mockery) displayCancellationSummary(stats executionStats, results []mockeryJob) { - completed := stats.succeeded + stats.failed - cancelled := stats.total - completed - - log.Warnf("⚠ Operation cancelled by user") - log.Infof("Completed: %d/%d packages", completed, stats.total) - log.Infof("Cancelled: %d packages", cancelled) - log.Infof("Duration: %.2fs", stats.duration.Seconds()) - - if stats.failed > 0 { - log.Warnf("Failed packages before cancellation:") - - for _, result := range results { - if result.err != nil && !errors.Is(result.err, context.Canceled) && !errors.Is(result.err, context.DeadlineExceeded) { - log.Errorf(" • %s", result.configFile) - log.Errorf(" %v", result.err) - } - } - } - - log.Info("Temporary files have been cleaned up") -} - -// generateRandomID generates a random hex string for temp file naming -func generateRandomID() (string, error) { - bytes := make([]byte, 4) // Reduced from 8 for shorter names - if _, err := rand.Read(bytes); err != nil { - return "", fmt.Errorf("failed to generate random ID: %w", err) - } - return hex.EncodeToString(bytes), nil -}