FossilRepo

fossilrepo / ctl / main.py
Source Blame History 495 lines
4ce269c… ragelink 1 """fossilrepo-ctl — operator CLI for the fossilrepo omnibus stack.
4ce269c… ragelink 2
4ce269c… ragelink 3 Similar to gitlab-ctl: manages the full stack (Django, Fossil, Caddy,
4ce269c… ragelink 4 Litestream, Celery, Postgres, Redis) as a single unit.
4ce269c… ragelink 5 """
4ce269c… ragelink 6
4ce269c… ragelink 7 import subprocess
4ce269c… ragelink 8 from pathlib import Path
4ce269c… ragelink 9
4ce269c… ragelink 10 import click
4ce269c… ragelink 11 from rich.console import Console
4ce269c… ragelink 12
4ce269c… ragelink 13 console = Console()
4ce269c… ragelink 14
4ce269c… ragelink 15 PROJECT_ROOT = Path(__file__).resolve().parent.parent
4ce269c… ragelink 16 COMPOSE_FILE = PROJECT_ROOT / "docker-compose.yaml"
4ce269c… ragelink 17 FOSSIL_COMPOSE_FILE = PROJECT_ROOT / "docker" / "docker-compose.fossil.yml"
4ce269c… ragelink 18
4ce269c… ragelink 19
4ce269c… ragelink 20 def _compose(*args: str, fossil: bool = False) -> subprocess.CompletedProcess:
4ce269c… ragelink 21 """Run a docker compose command."""
4ce269c… ragelink 22 cmd = ["docker", "compose", "-f", str(COMPOSE_FILE)]
4ce269c… ragelink 23 if fossil:
4ce269c… ragelink 24 cmd.extend(["-f", str(FOSSIL_COMPOSE_FILE)])
4ce269c… ragelink 25 cmd.extend(args)
4ce269c… ragelink 26 return subprocess.run(cmd, cwd=str(PROJECT_ROOT))
4ce269c… ragelink 27
4ce269c… ragelink 28
4ce269c… ragelink 29 @click.group()
4ce269c… ragelink 30 @click.version_option(version="0.1.0")
4ce269c… ragelink 31 def cli() -> None:
4ce269c… ragelink 32 """fossilrepo-ctl — manage the fossilrepo omnibus stack."""
4ce269c… ragelink 33
4ce269c… ragelink 34
4ce269c… ragelink 35 # ---------------------------------------------------------------------------
4ce269c… ragelink 36 # Stack commands (like gitlab-ctl start/stop/restart/status)
4ce269c… ragelink 37 # ---------------------------------------------------------------------------
4ce269c… ragelink 38
4ce269c… ragelink 39
4ce269c… ragelink 40 @cli.command()
4ce269c… ragelink 41 @click.option("--detach/--no-detach", "-d", default=True, help="Run in background.")
4ce269c… ragelink 42 def start(detach: bool) -> None:
4ce269c… ragelink 43 """Start the full fossilrepo stack."""
4ce269c… ragelink 44 console.print("[bold green]Starting fossilrepo stack...[/bold green]")
4ce269c… ragelink 45 args = ["up"]
4ce269c… ragelink 46 if detach:
4ce269c… ragelink 47 args.append("-d")
4ce269c… ragelink 48 _compose(*args)
4ce269c… ragelink 49
4ce269c… ragelink 50
4ce269c… ragelink 51 @cli.command()
4ce269c… ragelink 52 def stop() -> None:
4ce269c… ragelink 53 """Stop the full fossilrepo stack."""
4ce269c… ragelink 54 console.print("[bold yellow]Stopping fossilrepo stack...[/bold yellow]")
4ce269c… ragelink 55 _compose("down")
4ce269c… ragelink 56
4ce269c… ragelink 57
4ce269c… ragelink 58 @cli.command()
4ce269c… ragelink 59 def restart() -> None:
4ce269c… ragelink 60 """Restart the full fossilrepo stack."""
4ce269c… ragelink 61 console.print("[bold yellow]Restarting fossilrepo stack...[/bold yellow]")
4ce269c… ragelink 62 _compose("restart")
4ce269c… ragelink 63
4ce269c… ragelink 64
4ce269c… ragelink 65 @cli.command()
4ce269c… ragelink 66 def status() -> None:
4ce269c… ragelink 67 """Show status of all fossilrepo services."""
4ce269c… ragelink 68 _compose("ps")
4ce269c… ragelink 69
4ce269c… ragelink 70
4ce269c… ragelink 71 @cli.command()
4ce269c… ragelink 72 @click.argument("service", required=False)
4ce269c… ragelink 73 @click.option("--follow/--no-follow", "-f", default=True, help="Follow log output.")
4ce269c… ragelink 74 @click.option("--tail", default="100", help="Number of lines to show.")
4ce269c… ragelink 75 def logs(service: str | None, follow: bool, tail: str) -> None:
4ce269c… ragelink 76 """Tail logs from fossilrepo services."""
4ce269c… ragelink 77 args = ["logs", "--tail", tail]
4ce269c… ragelink 78 if follow:
4ce269c… ragelink 79 args.append("-f")
4ce269c… ragelink 80 if service:
4ce269c… ragelink 81 args.append(service)
4ce269c… ragelink 82 _compose(*args)
4ce269c… ragelink 83
4ce269c… ragelink 84
4ce269c… ragelink 85 # ---------------------------------------------------------------------------
4ce269c… ragelink 86 # Setup / reconfigure (like gitlab-ctl reconfigure)
4ce269c… ragelink 87 # ---------------------------------------------------------------------------
4ce269c… ragelink 88
4ce269c… ragelink 89
4ce269c… ragelink 90 @cli.command()
4ce269c… ragelink 91 def reconfigure() -> None:
4ce269c… ragelink 92 """Rebuild and reconfigure the stack (migrations, static files, etc.)."""
4ce269c… ragelink 93 console.print("[bold]Reconfiguring fossilrepo...[/bold]")
4ce269c… ragelink 94 _compose("build")
4ce269c… ragelink 95 _compose("up", "-d")
4ce269c… ragelink 96 console.print("[bold]Running migrations...[/bold]")
4ce269c… ragelink 97 _compose("exec", "backend", "python", "manage.py", "migrate")
4ce269c… ragelink 98 console.print("[bold]Collecting static files...[/bold]")
4ce269c… ragelink 99 _compose("exec", "backend", "python", "manage.py", "collectstatic", "--noinput")
4ce269c… ragelink 100 console.print("[bold green]Reconfiguration complete.[/bold green]")
4ce269c… ragelink 101
4ce269c… ragelink 102
4ce269c… ragelink 103 @cli.command()
4ce269c… ragelink 104 def seed() -> None:
4ce269c… ragelink 105 """Load seed data (dev users, sample data)."""
4ce269c… ragelink 106 _compose("exec", "backend", "python", "manage.py", "seed")
4ce269c… ragelink 107
4ce269c… ragelink 108
4ce269c… ragelink 109 # ---------------------------------------------------------------------------
4ce269c… ragelink 110 # Repo commands
4ce269c… ragelink 111 # ---------------------------------------------------------------------------
4ce269c… ragelink 112
4ce269c… ragelink 113
4ce269c… ragelink 114 @cli.group()
4ce269c… ragelink 115 def repo() -> None:
4ce269c… ragelink 116 """Manage Fossil repositories."""
4ce269c… ragelink 117
4ce269c… ragelink 118
4ce269c… ragelink 119 @repo.command()
4ce269c… ragelink 120 @click.argument("name")
4ce269c… ragelink 121 def create(name: str) -> None:
4ce269c… ragelink 122 """Create a new Fossil repository."""
4ce269c… ragelink 123 import django
4ce269c… ragelink 124
4ce269c… ragelink 125 django.setup()
4ce269c… ragelink 126
4ce269c… ragelink 127 from fossil.cli import FossilCLI
4ce269c… ragelink 128 from fossil.models import FossilRepository
4ce269c… ragelink 129 from organization.models import Organization
4ce269c… ragelink 130 from projects.models import Project
4ce269c… ragelink 131
4ce269c… ragelink 132 console.print(f"[bold]Creating repo:[/bold] {name}")
4ce269c… ragelink 133 cli = FossilCLI()
4ce269c… ragelink 134 if not cli.is_available():
4ce269c… ragelink 135 console.print("[red]Fossil binary not found.[/red]")
4ce269c… ragelink 136 return
4ce269c… ragelink 137
4ce269c… ragelink 138 org = Organization.objects.first()
4ce269c… ragelink 139 if not org:
4ce269c… ragelink 140 console.print("[red]No organization found. Run seed first.[/red]")
4ce269c… ragelink 141 return
4ce269c… ragelink 142
4ce269c… ragelink 143 project, created = Project.objects.get_or_create(name=name, defaults={"organization": org, "visibility": "private"})
4ce269c… ragelink 144 if created:
4ce269c… ragelink 145 console.print(f" Created project: [cyan]{project.slug}[/cyan]")
4ce269c… ragelink 146
4ce269c… ragelink 147 fossil_repo = FossilRepository.objects.filter(project=project).first()
4ce269c… ragelink 148 if fossil_repo and fossil_repo.exists_on_disk:
4ce269c… ragelink 149 console.print(f" Repo already exists: [cyan]{fossil_repo.full_path}[/cyan]")
4ce269c… ragelink 150 elif fossil_repo:
4ce269c… ragelink 151 cli.init(fossil_repo.full_path)
4ce269c… ragelink 152 fossil_repo.file_size_bytes = fossil_repo.full_path.stat().st_size
4ce269c… ragelink 153 fossil_repo.save(update_fields=["file_size_bytes", "updated_at", "version"])
4ce269c… ragelink 154 console.print(f" Initialized: [green]{fossil_repo.full_path}[/green]")
4ce269c… ragelink 155 console.print("[bold green]Done.[/bold green]")
4ce269c… ragelink 156
4ce269c… ragelink 157
4ce269c… ragelink 158 @repo.command(name="list")
4ce269c… ragelink 159 def list_repos() -> None:
4ce269c… ragelink 160 """List all Fossil repositories."""
4ce269c… ragelink 161 import django
4ce269c… ragelink 162
4ce269c… ragelink 163 django.setup()
4ce269c… ragelink 164 from rich.table import Table
4ce269c… ragelink 165
4ce269c… ragelink 166 from fossil.models import FossilRepository
4ce269c… ragelink 167
4ce269c… ragelink 168 repos = FossilRepository.objects.all()
4ce269c… ragelink 169 table = Table(title="Fossil Repositories")
4ce269c… ragelink 170 table.add_column("Project", style="cyan")
4ce269c… ragelink 171 table.add_column("Filename")
4ce269c… ragelink 172 table.add_column("Size", justify="right")
4ce269c… ragelink 173 table.add_column("On Disk", justify="center")
4ce269c… ragelink 174 for r in repos:
4ce269c… ragelink 175 size = f"{r.file_size_bytes / 1024:.0f} KB" if r.file_size_bytes else "—"
4ce269c… ragelink 176 table.add_row(r.project.name, r.filename, size, "yes" if r.exists_on_disk else "no")
4ce269c… ragelink 177 console.print(table)
4ce269c… ragelink 178
4ce269c… ragelink 179
4ce269c… ragelink 180 @repo.command()
4ce269c… ragelink 181 @click.argument("name")
4ce269c… ragelink 182 def delete(name: str) -> None:
4ce269c… ragelink 183 """Delete a Fossil repository (soft delete)."""
4ce269c… ragelink 184 import django
4ce269c… ragelink 185
4ce269c… ragelink 186 django.setup()
4ce269c… ragelink 187 from fossil.models import FossilRepository
4ce269c… ragelink 188
4ce269c… ragelink 189 console.print(f"[bold]Deleting repo:[/bold] {name}")
4ce269c… ragelink 190 repo = FossilRepository.objects.filter(filename=f"{name}.fossil").first()
4ce269c… ragelink 191 if not repo:
4ce269c… ragelink 192 console.print(f"[red]Repo not found: {name}.fossil[/red]")
4ce269c… ragelink 193 return
4ce269c… ragelink 194 repo.soft_delete()
4ce269c… ragelink 195 console.print(f" Soft-deleted: [yellow]{repo.filename}[/yellow]")
4ce269c… ragelink 196 console.print("[bold green]Done.[/bold green]")
4ce269c… ragelink 197
4ce269c… ragelink 198
4ce269c… ragelink 199 # ---------------------------------------------------------------------------
4ce269c… ragelink 200 # Sync commands
4ce269c… ragelink 201 # ---------------------------------------------------------------------------
4ce269c… ragelink 202
4ce269c… ragelink 203
4ce269c… ragelink 204 @cli.group()
4ce269c… ragelink 205 def sync() -> None:
4ce269c… ragelink 206 """Sync Fossil repos to GitHub/GitLab."""
4ce269c… ragelink 207
4ce269c… ragelink 208
4ce269c… ragelink 209 @sync.command(name="run")
4ce269c… ragelink 210 @click.argument("repo_name")
2eca4eb… ragelink 211 @click.option("--mirror-id", type=int, help="Specific Git mirror ID to sync.")
2eca4eb… ragelink 212 def sync_run(repo_name: str, mirror_id: int | None = None) -> None:
2eca4eb… ragelink 213 """Run Git sync for a Fossil repository."""
2eca4eb… ragelink 214 import django
2eca4eb… ragelink 215
2eca4eb… ragelink 216 django.setup()
2eca4eb… ragelink 217 from fossil.models import FossilRepository
2eca4eb… ragelink 218 from fossil.sync_models import GitMirror
2eca4eb… ragelink 219 from fossil.tasks import run_git_sync
2eca4eb… ragelink 220
2eca4eb… ragelink 221 repo = FossilRepository.objects.filter(filename=f"{repo_name}.fossil").first()
2eca4eb… ragelink 222 if not repo:
2eca4eb… ragelink 223 console.print(f"[red]Repo not found: {repo_name}.fossil[/red]")
2eca4eb… ragelink 224 return
2eca4eb… ragelink 225
2eca4eb… ragelink 226 mirrors = GitMirror.objects.filter(repository=repo, deleted_at__isnull=True).exclude(sync_mode="disabled")
2eca4eb… ragelink 227 if mirror_id:
2eca4eb… ragelink 228 mirrors = mirrors.filter(pk=mirror_id)
2eca4eb… ragelink 229
2eca4eb… ragelink 230 if not mirrors.exists():
2eca4eb… ragelink 231 console.print("[yellow]No Git mirrors configured for this repo.[/yellow]")
2eca4eb… ragelink 232 return
2eca4eb… ragelink 233
2eca4eb… ragelink 234 for mirror in mirrors:
2eca4eb… ragelink 235 console.print(f"[bold]Syncing[/bold] {repo.filename} → {mirror.git_remote_url}")
2eca4eb… ragelink 236 run_git_sync(mirror.pk)
2eca4eb… ragelink 237 mirror.refresh_from_db()
2eca4eb… ragelink 238 if mirror.last_sync_status == "success":
2eca4eb… ragelink 239 console.print(f" [green]Success[/green] — {mirror.last_sync_message[:100]}")
2eca4eb… ragelink 240 else:
2eca4eb… ragelink 241 console.print(f" [red]Failed[/red] — {mirror.last_sync_message[:100]}")
4ce269c… ragelink 242
4ce269c… ragelink 243
4ce269c… ragelink 244 @sync.command(name="status")
2eca4eb… ragelink 245 @click.argument("repo_name", required=False)
2eca4eb… ragelink 246 def sync_status(repo_name: str | None = None) -> None:
2eca4eb… ragelink 247 """Show sync status for repositories."""
2eca4eb… ragelink 248 import django
2eca4eb… ragelink 249
2eca4eb… ragelink 250 django.setup()
2eca4eb… ragelink 251 from rich.table import Table
2eca4eb… ragelink 252
2eca4eb… ragelink 253 from fossil.sync_models import GitMirror
2eca4eb… ragelink 254
2eca4eb… ragelink 255 mirrors = GitMirror.objects.filter(deleted_at__isnull=True)
2eca4eb… ragelink 256 if repo_name:
2eca4eb… ragelink 257 mirrors = mirrors.filter(repository__filename=f"{repo_name}.fossil")
2eca4eb… ragelink 258
2eca4eb… ragelink 259 table = Table(title="Git Mirror Status")
2eca4eb… ragelink 260 table.add_column("Repo", style="cyan")
2eca4eb… ragelink 261 table.add_column("Remote")
2eca4eb… ragelink 262 table.add_column("Mode")
2eca4eb… ragelink 263 table.add_column("Status")
2eca4eb… ragelink 264 table.add_column("Last Sync")
2eca4eb… ragelink 265 table.add_column("Syncs", justify="right")
2eca4eb… ragelink 266 for m in mirrors:
2eca4eb… ragelink 267 status_style = "green" if m.last_sync_status == "success" else "red" if m.last_sync_status == "failed" else "yellow"
2eca4eb… ragelink 268 table.add_row(
2eca4eb… ragelink 269 m.repository.filename,
2eca4eb… ragelink 270 m.git_remote_url[:40],
2eca4eb… ragelink 271 m.get_sync_mode_display(),
2eca4eb… ragelink 272 f"[{status_style}]{m.last_sync_status or 'never'}[/{status_style}]",
2eca4eb… ragelink 273 str(m.last_sync_at.strftime("%Y-%m-%d %H:%M") if m.last_sync_at else "—"),
2eca4eb… ragelink 274 str(m.total_syncs),
2eca4eb… ragelink 275 )
2eca4eb… ragelink 276 console.print(table)
4ce269c… ragelink 277
4ce269c… ragelink 278
4ce269c… ragelink 279 # ---------------------------------------------------------------------------
4ce269c… ragelink 280 # Backup commands
4ce269c… ragelink 281 # ---------------------------------------------------------------------------
4ce269c… ragelink 282
4ce269c… ragelink 283
4ce269c… ragelink 284 @cli.group()
4ce269c… ragelink 285 def backup() -> None:
4ce269c… ragelink 286 """Backup and restore operations."""
4ce269c… ragelink 287
4ce269c… ragelink 288
4ce269c… ragelink 289 @backup.command(name="create")
4ce269c… ragelink 290 def backup_create() -> None:
4ce269c… ragelink 291 """Create a backup of all repos and database."""
4ce269c… ragelink 292 console.print("[bold]Creating backup...[/bold]")
4ce269c… ragelink 293 raise NotImplementedError("Backup not yet implemented")
4ce269c… ragelink 294
4ce269c… ragelink 295
4ce269c… ragelink 296 @backup.command(name="restore")
4ce269c… ragelink 297 @click.argument("path")
4ce269c… ragelink 298 def backup_restore(path: str) -> None:
4ce269c… ragelink 299 """Restore from a backup."""
4ce269c… ragelink 300 console.print(f"[bold]Restoring from:[/bold] {path}")
4ce269c… ragelink 301 raise NotImplementedError("Restore not yet implemented")
c588255… ragelink 302
c588255… ragelink 303
c588255… ragelink 304 # ---------------------------------------------------------------------------
c588255… ragelink 305 # Bundle commands
c588255… ragelink 306 # ---------------------------------------------------------------------------
c588255… ragelink 307
c588255… ragelink 308
c588255… ragelink 309 @cli.group()
c588255… ragelink 310 def bundle() -> None:
c588255… ragelink 311 """Export and import Fossil repository bundles."""
c588255… ragelink 312
c588255… ragelink 313
c588255… ragelink 314 @bundle.command(name="export")
c588255… ragelink 315 @click.argument("project_slug")
c588255… ragelink 316 @click.argument("output_path")
c588255… ragelink 317 def bundle_export(project_slug: str, output_path: str) -> None:
c588255… ragelink 318 """Export a Fossil repo as a bundle file."""
c588255… ragelink 319 import django
c588255… ragelink 320
c588255… ragelink 321 django.setup()
c588255… ragelink 322
c588255… ragelink 323 from fossil.cli import FossilCLI
c588255… ragelink 324 from fossil.models import FossilRepository
c588255… ragelink 325
c588255… ragelink 326 repo = FossilRepository.objects.filter(project__slug=project_slug, deleted_at__isnull=True).first()
c588255… ragelink 327 if not repo:
c588255… ragelink 328 console.print(f"[red]No repository found for project: {project_slug}[/red]")
c588255… ragelink 329 return
c588255… ragelink 330
c588255… ragelink 331 if not repo.exists_on_disk:
c588255… ragelink 332 console.print(f"[red]Repository file not found on disk: {repo.full_path}[/red]")
c588255… ragelink 333 return
c588255… ragelink 334
c588255… ragelink 335 fossil_cli = FossilCLI()
c588255… ragelink 336 if not fossil_cli.is_available():
c588255… ragelink 337 console.print("[red]Fossil binary not found.[/red]")
c588255… ragelink 338 return
c588255… ragelink 339
c588255… ragelink 340 output = Path(output_path)
c588255… ragelink 341 output.parent.mkdir(parents=True, exist_ok=True)
c588255… ragelink 342
c588255… ragelink 343 console.print(f"[bold]Exporting bundle:[/bold] {repo.filename} -> {output}")
c588255… ragelink 344 try:
c588255… ragelink 345 result = subprocess.run(
c588255… ragelink 346 [fossil_cli.binary, "bundle", "export", str(output), "-R", str(repo.full_path)],
c588255… ragelink 347 capture_output=True,
c588255… ragelink 348 text=True,
c588255… ragelink 349 timeout=300,
c588255… ragelink 350 env=fossil_cli._env,
c588255… ragelink 351 )
c588255… ragelink 352 if result.returncode == 0:
c588255… ragelink 353 size_kb = output.stat().st_size / 1024
c588255… ragelink 354 console.print(f" [green]Success[/green] — {size_kb:.0f} KB written to {output}")
c588255… ragelink 355 else:
c588255… ragelink 356 console.print(f" [red]Failed[/red] — {result.stderr.strip() or result.stdout.strip()}")
c588255… ragelink 357 except subprocess.TimeoutExpired:
c588255… ragelink 358 console.print("[red]Export timed out after 5 minutes.[/red]")
c588255… ragelink 359
c588255… ragelink 360
c588255… ragelink 361 # ---------------------------------------------------------------------------
c588255… ragelink 362 # Update commands
c588255… ragelink 363 # ---------------------------------------------------------------------------
c588255… ragelink 364
c588255… ragelink 365
c588255… ragelink 366 @cli.command()
c588255… ragelink 367 @click.option("--source", type=click.Choice(["auto", "pypi", "git", "docker"]), default="auto", help="Update source.")
c588255… ragelink 368 def check_update(source: str) -> None:
c588255… ragelink 369 """Check for available updates."""
c588255… ragelink 370 import importlib.metadata
c588255… ragelink 371
c588255… ragelink 372 import requests
c588255… ragelink 373
c588255… ragelink 374 current = importlib.metadata.version("fossilrepo")
c588255… ragelink 375 console.print(f"[bold]Current version:[/bold] {current}")
c588255… ragelink 376
c588255… ragelink 377 if source == "auto":
c588255… ragelink 378 # Detect install source
c588255… ragelink 379 if (PROJECT_ROOT / ".git").exists():
c588255… ragelink 380 source = "git"
c588255… ragelink 381 elif COMPOSE_FILE.exists():
c588255… ragelink 382 source = "docker"
c588255… ragelink 383 else:
c588255… ragelink 384 source = "pypi"
c588255… ragelink 385
c588255… ragelink 386 latest = None
c588255… ragelink 387 if source == "pypi":
c588255… ragelink 388 console.print("[dim]Checking PyPI...[/dim]")
c588255… ragelink 389 try:
c588255… ragelink 390 resp = requests.get("https://pypi.org/pypi/fossilrepo/json", timeout=10)
c588255… ragelink 391 if resp.status_code == 200:
c588255… ragelink 392 latest = resp.json()["info"]["version"]
c588255… ragelink 393 except Exception:
c588255… ragelink 394 console.print("[yellow]Could not reach PyPI[/yellow]")
c588255… ragelink 395
c588255… ragelink 396 elif source == "git":
c588255… ragelink 397 console.print("[dim]Checking GitHub releases...[/dim]")
c588255… ragelink 398 try:
c588255… ragelink 399 resp = requests.get("https://api.github.com/repos/ConflictHQ/fossilrepo/releases/latest", timeout=10)
c588255… ragelink 400 if resp.status_code == 200:
c588255… ragelink 401 latest = resp.json()["tag_name"].lstrip("v")
c588255… ragelink 402 except Exception:
c588255… ragelink 403 console.print("[yellow]Could not reach GitHub[/yellow]")
c588255… ragelink 404
c588255… ragelink 405 elif source == "docker":
c588255… ragelink 406 console.print("[dim]Checking Docker Hub...[/dim]")
c588255… ragelink 407 try:
c588255… ragelink 408 resp = requests.get("https://hub.docker.com/v2/repositories/conflicthq/fossilrepo/tags/latest", timeout=10)
c588255… ragelink 409 if resp.status_code == 200:
c588255… ragelink 410 latest = resp.json().get("name", "unknown")
c588255… ragelink 411 except Exception:
c588255… ragelink 412 console.print("[yellow]Could not reach Docker Hub[/yellow]")
c588255… ragelink 413
c588255… ragelink 414 if latest:
c588255… ragelink 415 if latest != current:
c588255… ragelink 416 console.print(f"[bold green]Update available:[/bold green] {current} → {latest} (source: {source})")
c588255… ragelink 417 else:
c588255… ragelink 418 console.print(f"[green]Up to date.[/green] ({current}, source: {source})")
c588255… ragelink 419 else:
c588255… ragelink 420 console.print("[yellow]Could not determine latest version.[/yellow]")
c588255… ragelink 421
c588255… ragelink 422
c588255… ragelink 423 @cli.command()
c588255… ragelink 424 @click.option("--source", type=click.Choice(["auto", "pypi", "git"]), default="auto", help="Update source.")
c588255… ragelink 425 @click.confirmation_option(prompt="This will update fossilrepo and restart services. Continue?")
c588255… ragelink 426 def update(source: str) -> None:
c588255… ragelink 427 """Update fossilrepo to the latest version."""
c588255… ragelink 428 if source == "auto":
c588255… ragelink 429 if (PROJECT_ROOT / ".git").exists():
c588255… ragelink 430 source = "git"
c588255… ragelink 431 else:
c588255… ragelink 432 source = "pypi"
c588255… ragelink 433
c588255… ragelink 434 if source == "git":
c588255… ragelink 435 console.print("[bold]Pulling latest from git...[/bold]")
c588255… ragelink 436 subprocess.run(["git", "pull", "--ff-only"], cwd=str(PROJECT_ROOT), check=True)
c588255… ragelink 437 console.print("[bold]Installing dependencies...[/bold]")
c588255… ragelink 438 subprocess.run(["pip", "install", "-e", "."], cwd=str(PROJECT_ROOT), check=True)
c588255… ragelink 439 elif source == "pypi":
c588255… ragelink 440 console.print("[bold]Upgrading from PyPI...[/bold]")
c588255… ragelink 441 subprocess.run(["pip", "install", "--upgrade", "fossilrepo"], check=True)
c588255… ragelink 442
c588255… ragelink 443 console.print("[bold]Running migrations...[/bold]")
c588255… ragelink 444 subprocess.run(["python", "manage.py", "migrate", "--noinput"], cwd=str(PROJECT_ROOT), check=True)
c588255… ragelink 445 console.print("[bold]Collecting static files...[/bold]")
c588255… ragelink 446 subprocess.run(["python", "manage.py", "collectstatic", "--noinput"], cwd=str(PROJECT_ROOT), check=True)
c588255… ragelink 447 console.print("[bold green]Update complete. Restart services to apply.[/bold green]")
c588255… ragelink 448
c588255… ragelink 449
c588255… ragelink 450 @bundle.command(name="import")
c588255… ragelink 451 @click.argument("project_slug")
c588255… ragelink 452 @click.argument("input_path")
c588255… ragelink 453 def bundle_import(project_slug: str, input_path: str) -> None:
c588255… ragelink 454 """Import a Fossil bundle into an existing repo."""
c588255… ragelink 455 import django
c588255… ragelink 456
c588255… ragelink 457 django.setup()
c588255… ragelink 458
c588255… ragelink 459 from fossil.cli import FossilCLI
c588255… ragelink 460 from fossil.models import FossilRepository
c588255… ragelink 461
c588255… ragelink 462 repo = FossilRepository.objects.filter(project__slug=project_slug, deleted_at__isnull=True).first()
c588255… ragelink 463 if not repo:
c588255… ragelink 464 console.print(f"[red]No repository found for project: {project_slug}[/red]")
c588255… ragelink 465 return
c588255… ragelink 466
c588255… ragelink 467 if not repo.exists_on_disk:
c588255… ragelink 468 console.print(f"[red]Repository file not found on disk: {repo.full_path}[/red]")
c588255… ragelink 469 return
c588255… ragelink 470
c588255… ragelink 471 input_file = Path(input_path)
c588255… ragelink 472 if not input_file.exists():
c588255… ragelink 473 console.print(f"[red]Bundle file not found: {input_file}[/red]")
c588255… ragelink 474 return
c588255… ragelink 475
c588255… ragelink 476 fossil_cli = FossilCLI()
c588255… ragelink 477 if not fossil_cli.is_available():
c588255… ragelink 478 console.print("[red]Fossil binary not found.[/red]")
c588255… ragelink 479 return
c588255… ragelink 480
c588255… ragelink 481 console.print(f"[bold]Importing bundle:[/bold] {input_file} -> {repo.filename}")
c588255… ragelink 482 try:
c588255… ragelink 483 result = subprocess.run(
c588255… ragelink 484 [fossil_cli.binary, "bundle", "import", str(input_file), "-R", str(repo.full_path)],
c588255… ragelink 485 capture_output=True,
c588255… ragelink 486 text=True,
c588255… ragelink 487 timeout=300,
c588255… ragelink 488 env=fossil_cli._env,
c588255… ragelink 489 )
c588255… ragelink 490 if result.returncode == 0:
c588255… ragelink 491 console.print(f" [green]Success[/green] — {result.stdout.strip()}")
c588255… ragelink 492 else:
c588255… ragelink 493 console.print(f" [red]Failed[/red] — {result.stderr.strip() or result.stdout.strip()}")
c588255… ragelink 494 except subprocess.TimeoutExpired:
c588255… ragelink 495 console.print("[red]Import timed out after 5 minutes.[/red]")

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button