From 77e5afe0d04e5ad26bb952ae2bf888e904d0f128 Mon Sep 17 00:00:00 2001 From: 7000pctAUTO Date: Wed, 4 Feb 2026 20:05:33 +0000 Subject: [PATCH] Add CLI modules: validate, sync, merge, history --- confsync/cli/merge.py | 208 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 confsync/cli/merge.py diff --git a/confsync/cli/merge.py b/confsync/cli/merge.py new file mode 100644 index 0000000..856593e --- /dev/null +++ b/confsync/cli/merge.py @@ -0,0 +1,208 @@ +"""Merge command for intelligent configuration merging.""" + +from pathlib import Path +from typing import Optional +import typer +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from confsync.core.merger import Merger +from confsync.core.manifest import ManifestBuilder + +merge_cmd = typer.Typer( + name="merge", + help="Merge configuration files", + no_args_is_help=True, +) + +console = Console() + + +@merge_cmd.command("file") +def merge_file( + local: str = typer.Argument(..., help="Path to local configuration file"), + remote: str = typer.Argument(..., help="Path to remote configuration file"), + base: Optional[str] = typer.Option( + None, "--base", "-b", + help="Path to base/common configuration file" + ), + strategy: str = typer.Option( + "three_way", "--strategy", "-s", + help="Merge strategy (keep_local, keep_remote, keep_common, three_way, union)" + ), + output: str = typer.Option( + "merged_config.yaml", "--output", "-o", + help="Output file for merged configuration" + ), +): + """Merge two configuration files.""" + local_path = Path(local) + remote_path = Path(remote) + base_path = Path(base) if base else None + + if not local_path.exists(): + console.print(f"[red]Error:[/red] Local file not found: {local}") + return + + if not remote_path.exists(): + console.print(f"[red]Error:[/red] Remote file not found: {remote}") + return + + local_content = local_path.read_text() + remote_content = remote_path.read_text() + base_content = base_path.read_text() if base_path and base_path.exists() else "" + + merger = Merger() + success, merged, conflict = merger.merge_file( + local_content, remote_content, base_content, local, strategy + ) + + if success: + Path(output).write_text(merged) + console.print(Panel.fit( + "[bold green]Merge Successful![/bold green]", + style="green", + subtitle=f"Output: {output}", + )) + else: + console.print(Panel.fit( + "[bold yellow]Merge Conflicts Detected![/bold yellow]", + style="yellow", + )) + console.print("\n[bold]Conflicts:[/bold]") + if conflict: + console.print(conflict.format_conflict()) + + conflict_output = output + ".conflict" + Path(conflict_output).write_text(merged) + console.print(f"\n[yellow]Merged with conflicts written to {conflict_output}[/yellow]") + + +@merge_cmd.command("manifest") +def merge_manifest( + local_manifest: str = typer.Argument( + ..., + help="Path to local manifest" + ), + remote_manifest: str = typer.Argument( + ..., + help="Path to remote manifest" + ), + base_manifest: Optional[str] = typer.Option( + None, "--base", "-b", + help="Path to base/common manifest" + ), + strategy: str = typer.Option( + "local_wins", "--strategy", "-s", + help="Merge strategy (local_wins, remote_wins)" + ), + output: str = typer.Option( + "merged_manifest.yaml", "--output", "-o", + help="Output file for merged manifest" + ), +): + """Merge two manifests.""" + local_path = Path(local_manifest) + remote_path = Path(remote_manifest) + + if not local_path.exists(): + console.print(f"[red]Error:[/red] Local manifest not found: {local_manifest}") + return + + if not remote_path.exists(): + console.print(f"[red]Error:[/red] Remote manifest not found: {remote_manifest}") + return + + builder = ManifestBuilder() + local_manifest_obj = builder.load_manifest(local_manifest) + remote_manifest_obj = builder.load_manifest(remote_manifest) + + base_manifest_obj = None + if base_manifest: + base_path = Path(base_manifest) + if base_path.exists(): + base_manifest_obj = builder.load_manifest(base_manifest) + + merged = builder.merge_manifests( + base_manifest_obj or ManifestBuilder().build_from_detected([]), + remote_manifest_obj, + local_manifest_obj, + strategy + ) + + builder.save_manifest(merged, output) + + console.print(Panel.fit( + "[bold green]Manifest Merge Complete![/bold green]", + style="green", + subtitle=f"Output: {output}", + )) + console.print(f"Total entries: {len(merged.entries)}") + + +@merge_cmd.command("preview") +def merge_preview( + local: str = typer.Argument(..., help="Path to local configuration file"), + remote: str = typer.Argument(..., help="Path to remote configuration file"), + base: Optional[str] = typer.Option( + None, "--base", "-b", + help="Path to base/common configuration file" + ), + strategy: str = typer.Option( + "three_way", "--strategy", "-s", + help="Merge strategy to preview" + ), +): + """Preview merge result without writing files.""" + local_path = Path(local) + remote_path = Path(remote) + base_path = Path(base) if base else None + + local_content = local_path.read_text() if local_path.exists() else "" + remote_content = remote_path.read_text() if remote_path.exists() else "" + base_content = base_path.read_text() if base_path and base_path.exists() else "" + + merger = Merger() + success, merged, conflict = merger.merge_file( + local_content, remote_content, base_content, local, strategy + ) + + console.print("[bold]Merge Preview[/bold]") + console.print("-" * 40) + + if success: + console.print("[green]No conflicts would occur[/green]") + else: + console.print("[yellow]Conflicts would occur:[/yellow]") + if conflict: + console.print(conflict.format_conflict()) + + console.print("\n[bold]Merged Content Preview:[/bold]") + lines = merged.split('\n') + for i, line in enumerate(lines[:50], 1): + console.print(f"{i:4d} | {line}") + if len(lines) > 50: + console.print(f"... ({len(lines) - 50} more lines)") + + +@merge_cmd.command("strategies") +def list_strategies(): + """List available merge strategies.""" + table = Table(title="Available Merge Strategies") + table.add_column("Strategy", style="cyan") + table.add_column("Description", style="white") + table.add_column("Use Case", style="magenta") + + strategies = [ + ("keep_local", "Keep local changes, ignore remote", "Preserve local overrides"), + ("keep_remote", "Keep remote changes, ignore local", "Overwrite with remote"), + ("keep_common", "Keep only common lines", "Find shared configuration"), + ("three_way", "Standard three-way merge", "When both sides have changes"), + ("union", "Combine both, remove duplicates", "Union of all settings"), + ] + + for strategy, description, use_case in strategies: + table.add_row(strategy, description, use_case) + + console.print(table)