No results found.

Magento Module Configuration Diff

A Python script to easily generate a diff of module state between different Magento Configuration files.

Especially useful in CI pipelines for large config changes (Magento Updates) to attach a PR comment to aid in code review and ensure that changes to module configuration are intentional and expected.

Usage

Usage is simple, you call the script with up to 2 config file paths or git refs, and it will output the differences in module configuration between them. If no arguments are provided, it defaults to comparing the current local app/etc/config.php with the last committed version in git.

# Compare current config file with a specific git branch (production)
mage-module-compare production

# Comparing current config file with a specific git branch (staging) using a non standard path
mage-module-compare staging:src/app/etc/config.php

# Compare two specific git branches
mage-module-compare production staging

# Compare two specific git hashes
mage-module-compare 123abc 456def

# Compare two specific config files
mage-module-compare ~/Projects/project1/app/etc/config.php ~/Projects/project2/app/etc/config.php

# Compare piped config content with local config file
git show HEAD~6:app/etc/config.php | mage-module-compare

Installation

  1. Ensure you have Python 3, PHP and Git installed on your system.
  2. Save the script to a file, e.g. ~/.local/bin/mage-module-compare.
  3. Make the script executable: chmod +x ~/.local/bin/mage-module-compare.
  4. Optionally, ensure the script is in your PATH for easy access.
  5. Run the script with the desired config file paths or git refs to compare Magento module configurations.

Script

#!/usr/bin/env python3
# This script compares module config in magento config.php files, parsing module
# configuration and outputting differences in various formats.
#
# Usage:
#   python3 mage-module-compare.py <path_to_config1> <path_to_config2>  # Compare two files
#   python3 mage-module-compare.py <git_ref1:config_path> <git_ref2:config_path>  # Compare two refs
#   python3 mage-module-compare.py <path_to_config>                      # Compare with app/etc/config.php
#   python3 mage-module-compare.py                                       # Compare HEAD:app/etc/config.php with local app/etc/config.php
#   cat config.php | python3 mage-module-compare.py                      # Compare piped input with app/etc/config.php
#   python3 mage-module-compare.py HEAD:app/etc/config.php production:app/etc/config.php
#
# Output format:
#   --format list|markdown|csv|json   # default: list
import argparse
import csv
import json
import os
import subprocess
import sys

DEFAULT_CONFIG_PATH = "app/etc/config.php"


def extract_modules_from_content(config_content):
    """Extract modules from PHP config content by reading it directly."""
    command = "php -r '$config = eval(\"?>\".file_get_contents(\"php://stdin\")); echo json_encode($config[\"modules\"] ?? []);'"
    result = subprocess.run(command, shell=True, capture_output=True, text=True, input=config_content)

    if result.returncode != 0:
        print(f"Error extracting modules from config: {result.stderr}", file=sys.stderr)
        sys.exit(1)

    if result.stdout.strip() == "" or result.stdout == "null":
        print("No module configuration found in config", file=sys.stderr)
        sys.exit(1)

    try:
        return json.loads(result.stdout)
    except json.JSONDecodeError as error:
        print(f"Error parsing JSON output from PHP command: {error}", file=sys.stderr)
        print(f"Raw output was: {result.stdout}", file=sys.stderr)
        sys.exit(1)


def read_file_content(file_path):
    """Read content from local disk, or from git using ref:path (or ref + default path)."""
    if os.path.exists(file_path):
        try:
            with open(file_path, "r") as file_handle:
                return file_handle.read()
        except Exception as error:
            print(f"Error reading file {file_path}: {error}", file=sys.stderr)
            sys.exit(1)

    git_source = file_path if ":" in file_path else f"{file_path}:{DEFAULT_CONFIG_PATH}"
    result = subprocess.run(
        ["git", "show", git_source],
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        print(
            f"Error reading source '{file_path}' (resolved as '{git_source}'): {result.stderr.strip()}",
            file=sys.stderr,
        )
        sys.exit(1)
    return result.stdout


def extract_modules(config_path):
    """Extract modules from a config file path."""
    content = read_file_content(config_path)
    return extract_modules_from_content(content)


def get_differences(config1, config2):
    modules1 = set(config1.keys())
    modules2 = set(config2.keys())

    return {
        "added": sorted(modules2 - modules1),
        "removed": sorted(modules1 - modules2),
        "enabled": sorted(module for module in modules1 & modules2 if config1[module] == 0 and config2[module] == 1),
        "disabled": sorted(module for module in modules1 & modules2 if config1[module] == 1 and config2[module] == 0),
    }


def output_list(differences):
    print("Added Modules:")
    for module_name in differences["added"]:
        print(f"  {module_name}")

    print("\nRemoved Modules:")
    for module_name in differences["removed"]:
        print(f"  {module_name}")

    print("\nEnabled Modules:")
    for module_name in differences["enabled"]:
        print(f"  {module_name}")

    print("\nDisabled Modules:")
    for module_name in differences["disabled"]:
        print(f"  {module_name}")


def output_markdown(differences):
    sections = [
        ("Added Modules", differences["added"]),
        ("Removed Modules", differences["removed"]),
        ("Enabled Modules", differences["enabled"]),
        ("Disabled Modules", differences["disabled"]),
    ]

    for index, (title, modules) in enumerate(sections):
        print(f"## {title}")
        if modules:
            for module_name in modules:
                print(f"- {module_name}")
        else:
            print("- None")

        if index < len(sections) - 1:
            print()


def output_csv(differences):
    writer = csv.writer(sys.stdout)
    writer.writerow(["change_type", "module"])

    for change_type in ["added", "removed", "enabled", "disabled"]:
        for module_name in differences[change_type]:
            writer.writerow([change_type, module_name])


def output_json(differences):
    print(json.dumps(differences, indent=2))


def output_differences(differences, output_format):
    if output_format == "list":
        output_list(differences)
    elif output_format == "markdown":
        output_markdown(differences)
    elif output_format == "csv":
        output_csv(differences)
    elif output_format == "json":
        output_json(differences)
    else:
        print(f"Error: Unsupported output format '{output_format}'", file=sys.stderr)
        sys.exit(1)


def parse_args():
    parser = argparse.ArgumentParser(
        description="Compare Magento module configuration differences.",
    )
    parser.add_argument(
        "configs",
        nargs="*",
        help="0, 1, or 2 config sources (local file paths, git ref paths like HEAD:app/etc/config.php, or bare refs like HEAD)",
    )
    parser.add_argument(
        "-f",
        "--format",
        default="list",
        choices=["list", "markdown", "csv", "json"],
        help="Output format (default: list)",
    )
    return parser.parse_args()


if __name__ == "__main__":
    args = parse_args()
    is_piped = not sys.stdin.isatty()
    num_args = len(args.configs)
    output_format = args.format

    if num_args == 2:
        config1_path = args.configs[0]
        config2_path = args.configs[1]
        if output_format == "list":
            print(f"Comparing {config1_path} with {config2_path}\n")
        config1 = extract_modules(config1_path)
        config2 = extract_modules(config2_path)

    elif num_args == 1:
        config1_path = args.configs[0]
        config2_path = DEFAULT_CONFIG_PATH

        if not os.path.exists(config2_path):
            print(f"Error: Default config file {config2_path} not found", file=sys.stderr)
            sys.exit(1)

        if output_format == "list":
            print(f"Comparing {config1_path} with {config2_path}\n")
        config1 = extract_modules(config1_path)
        config2 = extract_modules(config2_path)

    elif num_args == 0 and is_piped:
        config2_path = DEFAULT_CONFIG_PATH

        if not os.path.exists(config2_path):
            print(f"Error: Default config file {config2_path} not found", file=sys.stderr)
            sys.exit(1)

        if output_format == "list":
            print(f"Comparing piped input with {config2_path}\n")
        piped_content = sys.stdin.read()
        config1 = extract_modules_from_content(piped_content)
        config2 = extract_modules(config2_path)

    elif num_args == 0:
        local_config_path = DEFAULT_CONFIG_PATH
        committed_config_path = f"HEAD:{DEFAULT_CONFIG_PATH}"

        if not os.path.exists(local_config_path):
            print(f"Error: Local config file {local_config_path} not found", file=sys.stderr)
            sys.exit(1)

        if output_format == "list":
            print(f"Comparing {committed_config_path} with local {local_config_path}\n")
        config1 = extract_modules(committed_config_path)
        config2 = extract_modules(local_config_path)

    else:
        print("Error: expected 0, 1, or 2 config arguments", file=sys.stderr)
        print("Run with --help for usage.", file=sys.stderr)
        sys.exit(2)

    differences = get_differences(config1, config2)
    output_differences(differences, output_format)