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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 25 additions & 32 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
24 changes: 17 additions & 7 deletions cmd/commands/mockery/mockery.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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)
}

Expand Down
129 changes: 129 additions & 0 deletions internal/actions/mockery/config.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading