Add CLI modules: validate, sync, merge, history
Some checks failed
CI / test (push) Has been cancelled
CI / build (push) Has been cancelled

This commit is contained in:
2026-02-04 20:05:33 +00:00
parent 731edad1f5
commit 77e5afe0d0

208
confsync/cli/merge.py Normal file
View File

@@ -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)