feat(cli): add initial repository management commands

- Introduce CLI using Typer for repository management
- Add commands for archiving, cleaning, moving, reviewing, and status
- Implement configuration loading and default structure setup
- Include utilities for Git status checks and directory size calculation
- Lay groundwork for extensible repository operations

Signed-off-by: Max P. <Mail@MPassarello.de>
This commit is contained in:
2025-04-30 12:01:34 +02:00
parent d1e246c868
commit ef2bac4e88
12 changed files with 702 additions and 0 deletions

109
src/repocat/cli/archive.py Normal file
View File

@@ -0,0 +1,109 @@
import shutil
import tarfile
from datetime import datetime, timedelta
from pathlib import Path
from typer import Typer, Option
from rich.console import Console
from rich.table import Table
import subprocess
import zstandard
import uuid
from repocat.config import load_config
from repocat.cli.clean import is_git_dirty, is_git_unpushed, get_dir_size
app = Typer()
console = Console()
def get_archive_target_dir():
config = load_config()
for cat in config.categories:
if cat.is_archive:
return config.base_dir / cat.subdir
raise ValueError("Keine Archiv-Kategorie (is_archive: true) in der Konfiguration gefunden.")
def archive_repo(source: Path, target_dir: Path, dry_run: bool):
today = datetime.today().strftime("%Y.%m.%d")
unique = uuid.uuid4().hex[:8]
archive_name = f"{today} - {source.name} - {unique}.tar.zst"
archive_path = target_dir / archive_name
if archive_path.exists():
raise FileExistsError(f"Archiv existiert bereits: {archive_path}")
if dry_run:
return archive_path # Nur anzeigen
with open(archive_path, "wb") as f:
cctx = zstandard.ZstdCompressor(level=20)
with cctx.stream_writer(f) as compressor:
with tarfile.open(fileobj=compressor, mode="w|") as tar:
tar.add(source, arcname=source.name)
shutil.rmtree(source)
return archive_path
@app.command("run")
def run(
older_than: int = Option(30, "--older-than", help="Nur Repositories älter als X Tage archivieren."),
dry_run: bool = Option(False, "--dry-run", help="Keine Änderungen vornehmen."),
category: list[str] = Option(None, "--category", "-c", help="Nur bestimmte Kategorien prüfen."),
):
config = load_config()
now = datetime.now()
archive_path = get_archive_target_dir()
archive_path.mkdir(parents=True, exist_ok=True)
base = config.base_dir
table = Table(title="Archivierung")
table.add_column("Kategorie")
table.add_column("Repository")
table.add_column("Alter", justify="right")
table.add_column("Größe (MB)", justify="right")
table.add_column("Aktion")
archived = 0
skipped = 0
for cat in config.categories:
if cat.is_archive or cat.name == "review" or cat.volatile:
continue
if category and cat.name not in category:
continue
source_dir = base / cat.subdir
if not source_dir.exists():
continue
for repo in sorted(source_dir.iterdir()):
if not repo.is_dir():
continue
mtime = datetime.fromtimestamp(repo.stat().st_mtime)
age_days = (now - mtime).days
size_mb = get_dir_size(repo) / 1024 / 1024
limit_days = cat.days if cat.days is not None else older_than
if age_days < limit_days:
table.add_row(cat.name, repo.name, str(age_days), f"{size_mb:.1f}", "[blue]Zu jung[/]")
skipped += 1
continue
if (repo / ".git").exists():
if is_git_dirty(repo) or is_git_unpushed(repo):
table.add_row(cat.name, repo.name, str(age_days), f"{size_mb:.1f}", "[blue]Geschützt (git)[/]")
skipped += 1
continue
try:
archive_file = archive_repo(repo, archive_path, dry_run)
action = "[yellow]Würde archivieren[/]" if dry_run else f"[green]Archiviert →[/] {archive_file.name}"
archived += 1
except Exception as e:
action = f"[red]Fehler: {e}[/]"
table.add_row(cat.name, repo.name, str(age_days), f"{size_mb:.1f}", action)
console.print(table)
console.print(f"\n[green]✓ Archivierung abgeschlossen[/] – {archived} archiviert, {skipped} übersprungen")