#!/usr/bin/env python3 """ Dev CLI - Chromium patch management tool A git-like patch management system for maintaining patches against Chromium. Enables extracting, applying, and managing patches across Chromium upgrades. """ import yaml from pathlib import Path from typing import Optional import typer from typer import Typer, Option, Argument # Import from common and utils from ..common.context import Context from ..common.utils import log_info, log_error, log_success, log_warning def create_build_context(chromium_src: Optional[Path] = None) -> Optional[Context]: """Create BuildContext for dev CLI operations""" try: if not chromium_src: log_error("Chromium source directory not specified") log_info( "Use --chromium-src option to specify the Chromium source directory" ) return None if not chromium_src.exists(): log_error(f"Chromium source directory does not exist: {chromium_src}") return None ctx = Context( chromium_src=chromium_src, architecture="", # Not needed for patch operations build_type="debug", # Not needed for patch operations ) return ctx except Exception as e: log_error(f"Failed to create build context: {e}") return None # Create the Typer app app = Typer( name="dev", help="BrowserOS dev CLI", no_args_is_help=True, pretty_exceptions_enable=False, pretty_exceptions_show_locals=False, ) # State class to hold global options class State: def __init__(self): self.chromium_src: Optional[Path] = None self.verbose: bool = False self.quiet: bool = False state = State() @app.callback() def main( chromium_src: Optional[Path] = Option( None, "--chromium-src", "-S", help="Path to Chromium source directory", exists=True, ), verbose: bool = Option(False, "--verbose", "-v", help="Enable verbose output"), quiet: bool = Option(False, "--quiet", "-q", help="Suppress non-essential output"), ): """ Dev CLI - Chromium patch management tool This tool provides git-like commands for managing patches against Chromium: Extract patches from commits: browseros dev extract commit HEAD browseros dev extract range HEAD~5 HEAD Apply patches: browseros dev apply all browseros dev apply feature llm-chat Manage features: browseros dev feature list browseros dev feature add my-feature HEAD browseros dev feature show my-feature """ state.chromium_src = chromium_src state.verbose = verbose state.quiet = quiet @app.command() def status(): """Show dev CLI status""" log_info("Dev CLI Status") log_info("-" * 40) build_ctx = create_build_context(state.chromium_src) if build_ctx: log_success(f"Chromium source: {build_ctx.chromium_src}") # Check for patches directory patches_dir = build_ctx.root_dir / "chromium_patches" if patches_dir.exists(): patch_count = len(list(patches_dir.rglob("*.patch"))) log_info(f"Individual patches: {patch_count}") else: log_warning("No patches directory found") # Check for features.yaml features_file = build_ctx.root_dir / "features.yaml" if features_file.exists(): with open(features_file) as f: features = yaml.safe_load(f) feature_count = len(features.get("features", {})) log_info(f"Features defined: {feature_count}") else: log_warning("No features.yaml found") else: log_error("Failed to create build context") # Create sub-apps for extract, apply, and feature commands extract_app = Typer( name="extract", help="Extract patches from commits", pretty_exceptions_enable=False, pretty_exceptions_show_locals=False, ) apply_app = Typer( name="apply", help="Apply patches to Chromium", pretty_exceptions_enable=False, pretty_exceptions_show_locals=False, ) feature_app = Typer( name="feature", help="Manage features", pretty_exceptions_enable=False, pretty_exceptions_show_locals=False, ) # Add sub-apps to main app app.add_typer(extract_app, name="extract") app.add_typer(apply_app, name="apply") app.add_typer(feature_app, name="feature") # Extract commands @extract_app.command(name="commit") def extract_commit( commit: str = Argument(..., help="Git commit reference (e.g., HEAD)"), output: Optional[Path] = Option(None, "--output", "-o", help="Output directory"), interactive: bool = Option( True, "--interactive/--no-interactive", "-i/-n", help="Interactive mode" ), force: bool = Option(False, "--force", "-f", help="Overwrite existing patches"), include_binary: bool = Option(False, "--include-binary", help="Include binary files"), base: Optional[str] = Option( None, "--base", help="Extract full diff from base commit for files in COMMIT" ), feature: bool = Option( False, "--feature", help="Add extracted files to a feature in features.yaml" ), ): """Extract patches from a single commit""" ctx = create_build_context(state.chromium_src) if not ctx: raise typer.Exit(1) from ..modules.extract import ExtractCommitModule module = ExtractCommitModule() try: module.validate(ctx) module.execute( ctx, commit=commit, output=output, interactive=interactive, verbose=state.verbose, force=force, include_binary=include_binary, base=base, feature=feature, ) except Exception as e: log_error(f"Failed to extract commit: {e}") raise typer.Exit(1) @extract_app.command(name="patch") def extract_patch_cmd( chromium_path: str = Argument(..., help="Chromium file path (e.g., chrome/common/foo.h)"), base: str = Option(..., "--base", "-b", help="Base commit to diff against"), force: bool = Option(False, "--force", "-f", help="Overwrite existing patch without prompting"), feature: bool = Option( False, "--feature", help="Add extracted file to a feature in features.yaml" ), ): """Extract patch for a specific file""" ctx = create_build_context(state.chromium_src) if not ctx: raise typer.Exit(1) from ..modules.extract import extract_single_file_patch success, error = extract_single_file_patch(ctx, chromium_path, base, force) if not success: log_error(error or "Unknown error") raise typer.Exit(1) log_success(f"Successfully extracted patch for: {chromium_path}") # Handle --feature flag if feature: from ..modules.feature import prompt_feature_selection, add_files_to_feature result = prompt_feature_selection(ctx, base[:12], None) if result is None: log_warning("Skipped adding file to feature") else: feature_name, description = result add_files_to_feature(ctx, feature_name, description, [chromium_path]) @extract_app.command(name="range") def extract_range( start: str = Argument(..., help="Start commit (exclusive)"), end: str = Argument(..., help="End commit (inclusive)"), output: Optional[Path] = Option(None, "--output", "-o", help="Output directory"), interactive: bool = Option( True, "--interactive/--no-interactive", "-i/-n", help="Interactive mode" ), force: bool = Option(False, "--force", "-f", help="Overwrite existing patches"), include_binary: bool = Option(False, "--include-binary", help="Include binary files"), squash: bool = Option(False, "--squash", help="Squash all commits into single patches"), base: Optional[str] = Option( None, "--base", help="Use different base for diff (full diff from base for files in range)", ), feature: bool = Option( False, "--feature", help="Add extracted files to a feature in features.yaml" ), ): """Extract patches from a range of commits""" ctx = create_build_context(state.chromium_src) if not ctx: raise typer.Exit(1) from ..modules.extract import ExtractRangeModule module = ExtractRangeModule() try: module.validate(ctx) module.execute( ctx, start=start, end=end, output=output, interactive=interactive, verbose=state.verbose, force=force, include_binary=include_binary, squash=squash, base=base, feature=feature, ) except Exception as e: log_error(f"Failed to extract range: {e}") raise typer.Exit(1) # Apply commands @apply_app.command(name="all") def apply_all( interactive: bool = Option( True, "--interactive/--no-interactive", "-i/-n", help="Interactive mode" ), reset_to: Optional[str] = Option( None, "--reset-to", "-r", help="Reset files to this commit before applying patches" ), annotate: bool = Option( False, "--annotate", "-a", help="Create git commits per feature after applying" ), ): """Apply all patches from chromium_patches/""" ctx = create_build_context(state.chromium_src) if not ctx: raise typer.Exit(1) from ..modules.apply import ApplyAllModule module = ApplyAllModule() try: module.validate(ctx) module.execute(ctx, interactive=interactive, reset_to=reset_to, annotate=annotate) except Exception as e: log_error(f"Failed to apply patches: {e}") raise typer.Exit(1) @apply_app.command(name="feature") def apply_feature( feature_name: str = Argument(..., help="Feature name to apply"), interactive: bool = Option( True, "--interactive/--no-interactive", "-i/-n", help="Interactive mode" ), reset_to: Optional[str] = Option( None, "--reset-to", "-r", help="Reset files to this commit before applying patches" ), annotate: bool = Option( False, "--annotate", "-a", help="Create git commit for this feature after applying" ), ): """Apply patches for a specific feature""" ctx = create_build_context(state.chromium_src) if not ctx: raise typer.Exit(1) from ..modules.apply import ApplyFeatureModule module = ApplyFeatureModule() try: module.validate(ctx) module.execute( ctx, feature_name=feature_name, interactive=interactive, reset_to=reset_to, annotate=annotate ) except Exception as e: log_error(f"Failed to apply feature: {e}") raise typer.Exit(1) @apply_app.command(name="patch") def apply_patch_cmd( chromium_path: str = Argument(..., help="Chromium file path (e.g., chrome/common/foo.h)"), reset_to: Optional[str] = Option( None, "--reset-to", "-r", help="Reset file to this commit before applying patch" ), dry_run: bool = Option(False, "--dry-run", help="Test without applying"), ): """Apply patch for a specific file""" ctx = create_build_context(state.chromium_src) if not ctx: raise typer.Exit(1) from ..modules.apply import apply_single_file_patch success, error = apply_single_file_patch(ctx, chromium_path, reset_to, dry_run) if not success: log_error(error or "Unknown error") raise typer.Exit(1) log_success(f"Successfully applied patch for: {chromium_path}") @apply_app.command(name="force") def apply_force( reset_to: Optional[str] = Option( None, "--reset-to", "-r", help="Reset files to this commit before applying patches" ), ): """Apply all patches non-interactively, writing .rej files for conflicts. Applies every patch without prompting. When a patch conflicts, uses git apply --reject to apply what it can and write .rej files for failed hunks, then continues to the next patch. Examples: browseros dev apply force -S /chromium browseros dev apply force --reset-to base -S /chromium """ ctx = create_build_context(state.chromium_src) if not ctx: raise typer.Exit(1) from ..modules.apply import ApplyForceModule module = ApplyForceModule() try: module.validate(ctx) module.execute(ctx, reset_to=reset_to) except Exception as e: log_error(f"Failed to apply patches: {e}") raise typer.Exit(1) @apply_app.command(name="changed") def apply_changed( commit: Optional[str] = Option( None, "--commit", "-c", help="Single commit hash to get changed patches from" ), range_start: Optional[str] = Option( None, "--range-start", help="Start commit of range (exclusive)" ), range_end: Optional[str] = Option( None, "--range-end", help="End commit of range (inclusive)" ), reset_to: str = Option( ..., "--reset-to", "-r", help="Reset chromium files to this commit before applying (required)" ), dry_run: bool = Option(False, "--dry-run", help="Preview changes without applying"), ): """Apply only patches that changed in specific commits. Useful for testing changes on another machine without full rebuild. Only resets and applies files that were modified in the specified commits. Examples: # Apply patches changed in a single commit browseros dev apply changed --commit 1c78477 --reset-to base -S /chromium # Apply patches changed in a range of commits browseros dev apply changed --range-start HEAD~5 --range-end HEAD --reset-to base -S /chromium """ ctx = create_build_context(state.chromium_src) if not ctx: raise typer.Exit(1) from ..modules.apply import ApplyChangedModule module = ApplyChangedModule() try: module.validate(ctx) module.execute( ctx, reset_to=reset_to, commit=commit, range_start=range_start, range_end=range_end, dry_run=dry_run, ) except Exception as e: log_error(f"Failed to apply changed patches: {e}") raise typer.Exit(1) # Feature commands @feature_app.command(name="list") def feature_list(): """List all defined features""" ctx = create_build_context(state.chromium_src) if not ctx: raise typer.Exit(1) from ..modules.feature import ListFeaturesModule module = ListFeaturesModule() try: module.validate(ctx) module.execute(ctx) except Exception as e: log_error(f"Failed to list features: {e}") raise typer.Exit(1) @feature_app.command(name="show") def feature_show( feature_name: str = Argument(..., help="Feature name to show"), ): """Show details of a specific feature""" ctx = create_build_context(state.chromium_src) if not ctx: raise typer.Exit(1) from ..modules.feature import ShowFeatureModule module = ShowFeatureModule() try: module.validate(ctx) module.execute(ctx, feature_name=feature_name) except Exception as e: log_error(f"Failed to show feature: {e}") raise typer.Exit(1) @feature_app.command(name="add-update") def feature_add_update( name: str = Option( ..., "--name", "-n", help="Feature name (lowercase kebab-case, e.g., 'llm-chat')" ), commit: str = Option(..., "--commit", "-c", help="Git commit reference"), description: str = Option( ..., "--description", "-d", help="Feature description with prefix (feat:, fix:, build:, chore:, series:)", ), ): """Add or update a feature with files from a commit. If the feature exists, merges new files into it. If the feature is new, creates it. Examples: browseros dev feature add-update --name llm-chat --commit HEAD -d "feat: LLM chat panel" browseros dev feature add-update -n api -c HEAD~3 -d "feat: browseros API updates" """ ctx = create_build_context(state.chromium_src) if not ctx: raise typer.Exit(1) from ..modules.feature import AddUpdateFeatureModule module = AddUpdateFeatureModule() try: module.validate(ctx) module.execute(ctx, name=name, commit=commit, description=description) except Exception as e: log_error(f"Failed to add/update feature: {e}") raise typer.Exit(1) @feature_app.command(name="classify") def feature_classify(): """Classify unclassified patch files into features Lists all patches in chromium_patches/ that are not in any feature, then prompts one-by-one to assign each to a feature. Examples: browseros dev feature classify """ ctx = create_build_context(state.chromium_src) if not ctx: raise typer.Exit(1) from ..modules.feature import ClassifyFeaturesModule module = ClassifyFeaturesModule() try: module.validate(ctx) module.execute(ctx) except Exception as e: log_error(f"Failed to classify features: {e}") raise typer.Exit(1) # Annotate command @app.command(name="annotate") def annotate_cmd( feature_name: Optional[str] = Argument( None, help="Optional: specific feature to annotate (default: all features)" ), ): """Create git commits organized by features from features.yaml For each feature with modified files, creates a commit with the format: "{feature_name}: {description}" Examples: browseros dev annotate -S /path/to/chromium browseros dev annotate llm-chat -S /path/to/chromium """ ctx = create_build_context(state.chromium_src) if not ctx: raise typer.Exit(1) from ..modules.annotate import AnnotateModule module = AnnotateModule() try: module.validate(ctx) module.execute(ctx, feature_name=feature_name) except Exception as e: log_error(f"Failed to annotate: {e}") raise typer.Exit(1) if __name__ == "__main__": app()