Coverage for src / gh_secrets_and_vars_async / chezmoi_cmd.py: 98%
64 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-16 17:56 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-16 17:56 +0000
1import asyncio
2import shutil
3import subprocess
4from pathlib import Path
6import click
7from loguru import logger
8from rich import print
10from .common import configure_logging
11from .push import perform_update
14def _run_chezmoi(
15 args: list[str], *, dry_run: bool = False, verbose: bool = False
16) -> subprocess.CompletedProcess[str]:
17 """Run a chezmoi command and return the result.
19 Uses list args (not shell=True) for cross-platform compatibility.
20 Raises click.ClickException on non-zero exit codes.
21 """
22 cmd = ["chezmoi", *args]
23 if dry_run:
24 print(f"[dim]\\[DRY RUN] Would run: {' '.join(cmd)}[/dim]")
25 return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
27 logger.debug(f"Running: {cmd}")
28 result = subprocess.run(cmd, capture_output=True, text=True)
30 if verbose and result.stdout:
31 print(result.stdout.rstrip())
33 if result.returncode != 0:
34 detail = result.stderr.strip() or result.stdout.strip()
35 raise click.ClickException(f"chezmoi {' '.join(args)} failed: {detail}")
37 return result
40def _build_commit_message(project_name: str, status_output: str) -> str:
41 """Build a descriptive commit message from chezmoi git status --porcelain output."""
42 files = []
43 for line in status_output.strip().splitlines():
44 # Porcelain format: "XY filename" or "XY filename -> renamed"
45 if len(line) > 3:
46 files.append(line[3:].strip())
48 file_list = ", ".join(files) if files else "env files"
49 return f"chezmoi: sync {project_name} env files\n\nFiles: {file_list}"
52@click.command("chezmoi")
53@click.option("--save", is_flag=True, default=False, help="Explicit save flag (default behavior).")
54@click.option("--no-sync", is_flag=True, help="Skip pushing secrets to GitHub.")
55@click.option("--verbose", "-v", is_flag=True, help="Print detailed output.")
56@click.option(
57 "--dry-run", "-d", is_flag=True, help="Show what would be done without making changes."
58)
59@click.argument("filename", type=click.Path(), default=".env")
60def chezmoi_command(save: bool, no_sync: bool, verbose: bool, dry_run: bool, filename: str) -> None:
61 """Back up .env to chezmoi and sync secrets to GitHub."""
62 configure_logging(verbose)
64 # Validate chezmoi is installed
65 if not shutil.which("chezmoi"):
66 raise click.ClickException(
67 "chezmoi is not installed. Install from https://chezmoi.io/install/"
68 )
70 env_path = Path(filename).resolve()
72 # Validate file exists
73 if not env_path.exists():
74 raise click.ClickException(f"File not found: {env_path}")
76 project_name = Path.cwd().name
78 # Step 1: Add .env to chezmoi source
79 print(f"[bold]Adding {filename} to chezmoi...[/bold]")
80 _run_chezmoi(["add", str(env_path)], dry_run=dry_run, verbose=verbose)
82 # Step 2: Stage changes
83 _run_chezmoi(["git", "add", "--", "."], dry_run=dry_run, verbose=verbose)
85 # Step 3: Check for changes
86 status_result = _run_chezmoi(
87 ["git", "status", "--", "--porcelain"], dry_run=dry_run, verbose=verbose
88 )
89 status_output = status_result.stdout.strip()
91 if status_output or dry_run:
92 # Step 4: Commit
93 message = _build_commit_message(project_name, status_output)
94 print("[bold]Committing to chezmoi...[/bold]")
95 _run_chezmoi(["git", "commit", "--", "-m", message], dry_run=dry_run, verbose=verbose)
97 # Step 5: Pull --rebase (safe: working tree is clean after commit)
98 print("[bold]Syncing with chezmoi remote...[/bold]")
99 _run_chezmoi(["git", "pull", "--", "--rebase"], dry_run=dry_run, verbose=verbose)
101 # Step 6: Push
102 _run_chezmoi(["git", "push"], dry_run=dry_run, verbose=verbose)
103 print("[green]chezmoi backup complete.[/green]")
104 else:
105 print("[yellow]No chezmoi changes to commit.[/yellow]")
107 # Step 7: Push secrets to GitHub
108 if not no_sync:
109 print("\n[bold]Syncing secrets to GitHub...[/bold]")
110 results = asyncio.run(perform_update(filename, verbose, dry_run))
111 total_secrets = len(results["SECRETS"])
112 total_vars = len(results["VARIABLES"])
113 print(f"[green]Updated {total_secrets} secrets and {total_vars} variables.[/green]")
114 else:
115 print("[dim]Skipping GitHub sync (--no-sync).[/dim]")