|
| 1 | +""" |
| 2 | +Update command implementation. |
| 3 | +
|
| 4 | +Updates an existing Aegis Stack project to a newer template version using |
| 5 | +Copier's git-aware update mechanism. |
| 6 | +""" |
| 7 | + |
| 8 | +from pathlib import Path |
| 9 | + |
| 10 | +import typer |
| 11 | + |
| 12 | +from ..core.copier_manager import is_copier_project, load_copier_answers |
| 13 | +from ..core.copier_updater import ( |
| 14 | + get_changelog, |
| 15 | + get_current_template_commit, |
| 16 | + get_latest_version, |
| 17 | + get_template_root, |
| 18 | + resolve_version_to_ref, |
| 19 | + validate_clean_git_tree, |
| 20 | +) |
| 21 | +from ..core.post_gen_tasks import run_post_generation_tasks |
| 22 | +from ..core.version_compatibility import get_cli_version, get_project_template_version |
| 23 | + |
| 24 | + |
| 25 | +def update_command( |
| 26 | + to_version: str | None = typer.Option( |
| 27 | + None, |
| 28 | + "--to-version", |
| 29 | + help="Update to specific version (default: latest)", |
| 30 | + ), |
| 31 | + dry_run: bool = typer.Option( |
| 32 | + False, |
| 33 | + "--dry-run", |
| 34 | + help="Preview changes without applying", |
| 35 | + ), |
| 36 | + project_path: str = typer.Option( |
| 37 | + ".", |
| 38 | + "--project-path", |
| 39 | + "-p", |
| 40 | + help="Path to the Aegis Stack project (default: current directory)", |
| 41 | + ), |
| 42 | + yes: bool = typer.Option( |
| 43 | + False, |
| 44 | + "--yes", |
| 45 | + "-y", |
| 46 | + help="Skip confirmation prompt", |
| 47 | + ), |
| 48 | +) -> None: |
| 49 | + """ |
| 50 | + Update project to a newer template version. |
| 51 | +
|
| 52 | + This command uses Copier's git-aware update mechanism to merge template |
| 53 | + changes into your project while preserving your customizations. |
| 54 | +
|
| 55 | + Examples: |
| 56 | +
|
| 57 | + - aegis update |
| 58 | +
|
| 59 | + - aegis update --to-version 0.2.0 |
| 60 | +
|
| 61 | + - aegis update --dry-run |
| 62 | +
|
| 63 | + - aegis update --project-path ../my-project |
| 64 | +
|
| 65 | + Note: This command requires a clean git working tree. |
| 66 | + """ |
| 67 | + |
| 68 | + typer.echo("🛡️ Aegis Stack - Update Template") |
| 69 | + typer.echo("=" * 50) |
| 70 | + |
| 71 | + # Resolve project path |
| 72 | + target_path = Path(project_path).resolve() |
| 73 | + |
| 74 | + # Validate it's a Copier project |
| 75 | + if not is_copier_project(target_path): |
| 76 | + typer.echo( |
| 77 | + f"❌ Project at {target_path} was not generated with Copier.", err=True |
| 78 | + ) |
| 79 | + typer.echo( |
| 80 | + " The 'aegis update' command only works with Copier-generated projects.", |
| 81 | + err=True, |
| 82 | + ) |
| 83 | + typer.echo( |
| 84 | + " Projects generated before v0.2.0 need to be regenerated.", |
| 85 | + err=True, |
| 86 | + ) |
| 87 | + raise typer.Exit(1) |
| 88 | + |
| 89 | + typer.echo(f"📁 Project: {target_path}") |
| 90 | + |
| 91 | + # Check git status |
| 92 | + is_clean, git_message = validate_clean_git_tree(target_path) |
| 93 | + if not is_clean: |
| 94 | + typer.echo(f"❌ {git_message}", err=True) |
| 95 | + typer.echo( |
| 96 | + " Commit or stash your changes before running 'aegis update'.", |
| 97 | + err=True, |
| 98 | + ) |
| 99 | + typer.echo( |
| 100 | + " Copier requires a clean git tree to safely merge changes.", err=True |
| 101 | + ) |
| 102 | + raise typer.Exit(1) |
| 103 | + |
| 104 | + typer.echo("✅ Git tree is clean") |
| 105 | + |
| 106 | + # Get current template version |
| 107 | + current_commit = get_current_template_commit(target_path) |
| 108 | + if not current_commit: |
| 109 | + typer.secho( |
| 110 | + "⚠️ Warning: Cannot determine current template version", fg="yellow" |
| 111 | + ) |
| 112 | + typer.echo(" Project may have been generated from an untagged commit") |
| 113 | + |
| 114 | + current_version = get_project_template_version(target_path) |
| 115 | + cli_version = get_cli_version() |
| 116 | + |
| 117 | + # Get template root |
| 118 | + template_root = get_template_root() |
| 119 | + |
| 120 | + # Resolve target version |
| 121 | + if to_version: |
| 122 | + target_ref = resolve_version_to_ref(to_version, template_root) |
| 123 | + target_version_display = to_version |
| 124 | + else: |
| 125 | + # Default to latest |
| 126 | + latest = get_latest_version(template_root) |
| 127 | + if latest: |
| 128 | + target_ref = f"v{latest}" |
| 129 | + target_version_display = f"{latest} (latest)" |
| 130 | + else: |
| 131 | + target_ref = "HEAD" |
| 132 | + target_version_display = "HEAD (latest commit)" |
| 133 | + |
| 134 | + # Display version information |
| 135 | + typer.echo("") |
| 136 | + typer.echo("📦 Version Information:") |
| 137 | + typer.echo(f" Current CLI: {cli_version}") |
| 138 | + if current_version: |
| 139 | + typer.echo(f" Current Template: {current_version}") |
| 140 | + elif current_commit: |
| 141 | + typer.echo(f" Current Template: {current_commit[:8]}... (commit)") |
| 142 | + else: |
| 143 | + typer.echo(" Current Template: unknown") |
| 144 | + typer.echo(f" Target Template: {target_version_display}") |
| 145 | + |
| 146 | + # Check if already up to date (version-based) |
| 147 | + if current_version and to_version and current_version == to_version: |
| 148 | + typer.echo("") |
| 149 | + typer.secho("✅ Project is already at the requested version", fg="green") |
| 150 | + return |
| 151 | + |
| 152 | + # Check if already at target commit (for HEAD/branch updates) |
| 153 | + if current_commit and target_ref: |
| 154 | + from ..core.copier_updater import resolve_ref_to_commit |
| 155 | + |
| 156 | + target_commit = resolve_ref_to_commit(target_ref, template_root) |
| 157 | + |
| 158 | + if target_commit and current_commit == target_commit: |
| 159 | + typer.echo("") |
| 160 | + typer.secho("✅ Project is already at the target commit", fg="green") |
| 161 | + typer.echo(f" Current: {current_commit[:8]}...") |
| 162 | + typer.echo(f" Target: {target_commit[:8]}...") |
| 163 | + return |
| 164 | + |
| 165 | + # Get and display changelog |
| 166 | + if current_commit: |
| 167 | + typer.echo("") |
| 168 | + typer.echo("📋 Changelog:") |
| 169 | + typer.echo("-" * 50) |
| 170 | + changelog = get_changelog(current_commit, target_ref, template_root) |
| 171 | + typer.echo(changelog) |
| 172 | + typer.echo("-" * 50) |
| 173 | + |
| 174 | + # Dry run mode |
| 175 | + if dry_run: |
| 176 | + typer.echo("") |
| 177 | + typer.secho("🔍 DRY RUN MODE - No changes will be applied", fg="cyan") |
| 178 | + typer.echo("") |
| 179 | + typer.echo("To apply this update, run:") |
| 180 | + if to_version: |
| 181 | + typer.echo(f" aegis update --to-version {to_version}") |
| 182 | + else: |
| 183 | + typer.echo(" aegis update") |
| 184 | + return |
| 185 | + |
| 186 | + # Confirmation |
| 187 | + if not yes: |
| 188 | + typer.echo("") |
| 189 | + if not typer.confirm("🚀 Apply this update?"): |
| 190 | + typer.echo("❌ Update cancelled") |
| 191 | + raise typer.Exit(0) |
| 192 | + |
| 193 | + # Perform update using Copier |
| 194 | + typer.echo("") |
| 195 | + typer.echo("🔄 Updating project...") |
| 196 | + |
| 197 | + try: |
| 198 | + # Import here to avoid circular dependency |
| 199 | + from copier import run_update |
| 200 | + |
| 201 | + # Run Copier update with git-aware merge |
| 202 | + run_update( |
| 203 | + dst_path=str(target_path), |
| 204 | + src_path=str(template_root), |
| 205 | + defaults=True, # Use existing answers as defaults |
| 206 | + overwrite=True, # Allow overwriting files |
| 207 | + conflict="rej", # Create .rej files for conflicts |
| 208 | + unsafe=False, # Disable _tasks (we run them ourselves) |
| 209 | + vcs_ref=target_ref, # Use specified version |
| 210 | + ) |
| 211 | + |
| 212 | + # Load answers to determine what services are enabled |
| 213 | + answers = load_copier_answers(target_path) |
| 214 | + include_auth = answers.get("include_auth", False) |
| 215 | + |
| 216 | + # Run post-generation tasks |
| 217 | + typer.echo("🔨 Running post-generation tasks...") |
| 218 | + run_post_generation_tasks(target_path, include_auth=include_auth) |
| 219 | + |
| 220 | + # Success! |
| 221 | + typer.echo("") |
| 222 | + typer.secho("✅ Update completed successfully!", fg="green") |
| 223 | + typer.echo("") |
| 224 | + typer.echo("📝 Next Steps:") |
| 225 | + typer.echo(" 1. Review changes: git diff") |
| 226 | + typer.echo(" 2. Check for conflicts (*.rej files)") |
| 227 | + typer.echo(" 3. Run tests: make check") |
| 228 | + typer.echo(" 4. Commit changes: git add . && git commit") |
| 229 | + |
| 230 | + # Check for conflict files |
| 231 | + rej_files = list(target_path.rglob("*.rej")) |
| 232 | + if rej_files: |
| 233 | + typer.echo("") |
| 234 | + typer.secho( |
| 235 | + f"⚠️ Found {len(rej_files)} conflict file(s) - manual resolution required", |
| 236 | + fg="yellow", |
| 237 | + ) |
| 238 | + typer.echo(" Conflicts:") |
| 239 | + for rej_file in rej_files[:5]: # Show first 5 |
| 240 | + typer.echo(f" - {rej_file.relative_to(target_path)}") |
| 241 | + if len(rej_files) > 5: |
| 242 | + typer.echo(f" ... and {len(rej_files) - 5} more") |
| 243 | + |
| 244 | + except Exception as e: |
| 245 | + typer.echo("") |
| 246 | + typer.secho(f"❌ Update failed: {e}", fg="red", err=True) |
| 247 | + typer.echo("") |
| 248 | + typer.echo("💡 Troubleshooting:") |
| 249 | + typer.echo(" - Ensure you have a clean git tree") |
| 250 | + typer.echo(" - Check that the version/commit exists") |
| 251 | + typer.echo(" - Review Copier documentation for update issues") |
| 252 | + raise typer.Exit(1) |
0 commit comments