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

1import asyncio 

2import shutil 

3import subprocess 

4from pathlib import Path 

5 

6import click 

7from loguru import logger 

8from rich import print 

9 

10from .common import configure_logging 

11from .push import perform_update 

12 

13 

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. 

18 

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="") 

26 

27 logger.debug(f"Running: {cmd}") 

28 result = subprocess.run(cmd, capture_output=True, text=True) 

29 

30 if verbose and result.stdout: 

31 print(result.stdout.rstrip()) 

32 

33 if result.returncode != 0: 

34 detail = result.stderr.strip() or result.stdout.strip() 

35 raise click.ClickException(f"chezmoi {' '.join(args)} failed: {detail}") 

36 

37 return result 

38 

39 

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

47 

48 file_list = ", ".join(files) if files else "env files" 

49 return f"chezmoi: sync {project_name} env files\n\nFiles: {file_list}" 

50 

51 

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) 

63 

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 ) 

69 

70 env_path = Path(filename).resolve() 

71 

72 # Validate file exists 

73 if not env_path.exists(): 

74 raise click.ClickException(f"File not found: {env_path}") 

75 

76 project_name = Path.cwd().name 

77 

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) 

81 

82 # Step 2: Stage changes 

83 _run_chezmoi(["git", "add", "--", "."], dry_run=dry_run, verbose=verbose) 

84 

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

90 

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) 

96 

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) 

100 

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]") 

106 

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]")