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:
0
src/repocat/__init__.py
Normal file
0
src/repocat/__init__.py
Normal file
7
src/repocat/__main__.py
Normal file
7
src/repocat/__main__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from repocat.cli import app
|
||||
|
||||
def main():
|
||||
app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
12
src/repocat/cli/__init__.py
Normal file
12
src/repocat/cli/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from typer import Typer
|
||||
|
||||
from repocat.cli import archive, clean, init, move, review, status
|
||||
|
||||
app = Typer()
|
||||
|
||||
app.add_typer(init.app, name="init")
|
||||
app.add_typer(clean.app, name="clean")
|
||||
app.add_typer(move.app, name=None)
|
||||
app.add_typer(status.app, name=None)
|
||||
app.add_typer(review.app, name="review")
|
||||
app.add_typer(archive.app, name="archive")
|
109
src/repocat/cli/archive.py
Normal file
109
src/repocat/cli/archive.py
Normal 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")
|
185
src/repocat/cli/clean.py
Normal file
185
src/repocat/cli/clean.py
Normal file
@@ -0,0 +1,185 @@
|
||||
import subprocess
|
||||
from typer import Typer, Option, Argument
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from datetime import datetime, timedelta
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
import os
|
||||
|
||||
from repocat.config import load_config
|
||||
|
||||
app = Typer()
|
||||
console = Console()
|
||||
|
||||
def is_git_dirty(path: Path) -> bool:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "status", "--porcelain"],
|
||||
cwd=path,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
)
|
||||
return bool(result.stdout.strip())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def is_git_unpushed(path: Path) -> bool:
|
||||
try:
|
||||
# Check if upstream exists
|
||||
subprocess.run(["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
|
||||
cwd=path, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
|
||||
|
||||
result = subprocess.run(
|
||||
["git", "log", "@{u}..HEAD", "--oneline"],
|
||||
cwd=path,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
)
|
||||
return bool(result.stdout.strip())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_dir_size(path: Path) -> int:
|
||||
"""Returns size in bytes."""
|
||||
return sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
|
||||
|
||||
def complete_categories(incomplete: str):
|
||||
config = load_config()
|
||||
return [c.name for c in config.categories if c.name.startswith(incomplete)]
|
||||
|
||||
@app.command("volatile")
|
||||
def clean_volatile(
|
||||
dry_run: bool = Option(False, "--dry-run", help="Nur anzeigen, was gelöscht würde"),
|
||||
category: list[str] = Option(None, "--category", "-c", help="Nur bestimmte Kategorien reinigen", autocompletion=complete_categories),
|
||||
):
|
||||
"""
|
||||
Löscht **bedingungslos** alle Repositories aus `volatile`-Kategorien (z. B. `scratches`).
|
||||
"""
|
||||
config = load_config()
|
||||
base = config.base_dir
|
||||
|
||||
table = Table(title="Clean – Volatile (Zerstörung)")
|
||||
table.add_column("Kategorie")
|
||||
table.add_column("Repository")
|
||||
table.add_column("Größe (MB)", justify="right")
|
||||
table.add_column("Aktion")
|
||||
|
||||
deleted = 0
|
||||
skipped = 0
|
||||
|
||||
for cat in config.categories:
|
||||
if not cat.volatile:
|
||||
continue
|
||||
if category and cat.name not in category:
|
||||
continue
|
||||
|
||||
path = base / cat.subdir
|
||||
if not path.exists():
|
||||
continue
|
||||
|
||||
for repo in sorted(path.iterdir()):
|
||||
if not repo.is_dir():
|
||||
continue
|
||||
|
||||
size_mb = get_dir_size(repo) / 1024 / 1024
|
||||
|
||||
if dry_run:
|
||||
action = "[yellow]Würde löschen[/]"
|
||||
else:
|
||||
try:
|
||||
shutil.rmtree(repo)
|
||||
action = "[red]Gelöscht[/]"
|
||||
deleted += 1
|
||||
except Exception as e:
|
||||
action = f"[red]Fehler: {e}[/]"
|
||||
|
||||
table.add_row(cat.name, repo.name, f"{size_mb:.1f}", action)
|
||||
|
||||
console.print(table)
|
||||
console.print(f"\n[green]✓ Volatile-Clean abgeschlossen[/] – {deleted} gelöscht, {skipped} übersprungen")
|
||||
|
||||
@app.command()
|
||||
def run(
|
||||
dry_run: bool = Option(False, "--dry-run", help="Nur anzeigen, was gelöscht würde"),
|
||||
category: list[str] = Option(None, "--category", "-c", help="Nur bestimmte Kategorien reinigen", autocompletion=complete_categories),
|
||||
):
|
||||
"""
|
||||
Löscht alte oder volatile Repositories aus den konfigurierten Kategorien.
|
||||
"""
|
||||
config = load_config()
|
||||
now = datetime.now()
|
||||
base = config.base_dir
|
||||
|
||||
console.print(f"[bold yellow]Base-Verzeichnis:[/] {base}")
|
||||
|
||||
table = Table(title="Clean-Ergebnis")
|
||||
table.add_column("Kategorie")
|
||||
table.add_column("Repository")
|
||||
table.add_column("Alter (Tage)", justify="right")
|
||||
table.add_column("Größe (MB)", justify="right")
|
||||
table.add_column("Aktion")
|
||||
|
||||
deleted = 0
|
||||
skipped = 0
|
||||
|
||||
for cat in config.categories:
|
||||
if category and cat.name not in category:
|
||||
continue
|
||||
|
||||
if cat.is_archive: continue
|
||||
|
||||
path = base / cat.subdir
|
||||
if not path.exists():
|
||||
continue
|
||||
|
||||
cutoff = now - timedelta(days=cat.days or 0)
|
||||
|
||||
for repo in sorted(path.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
|
||||
|
||||
should_delete = (
|
||||
(cat.volatile)
|
||||
or (cat.days is not None and mtime < cutoff)
|
||||
)
|
||||
|
||||
# Git-Schutz prüfen
|
||||
is_git = (repo / ".git").exists()
|
||||
dirty = is_git and is_git_dirty(repo)
|
||||
unpushed = is_git and is_git_unpushed(repo)
|
||||
|
||||
if is_git and (dirty or unpushed):
|
||||
reason = []
|
||||
if dirty:
|
||||
reason.append("uncommitted")
|
||||
if unpushed:
|
||||
reason.append("unpushed")
|
||||
status = "/".join(reason)
|
||||
action = f"[blue]Geschützt ({status})[/]"
|
||||
skipped += 1
|
||||
else:
|
||||
if should_delete:
|
||||
if dry_run:
|
||||
action = "[yellow]Würde löschen[/]"
|
||||
else:
|
||||
try:
|
||||
shutil.rmtree(repo)
|
||||
action = "[red]Gelöscht[/]"
|
||||
deleted += 1
|
||||
except Exception as e:
|
||||
action = f"[red]Fehler: {e}[/]"
|
||||
else:
|
||||
action = "[cyan]Keine Aktion[/]"
|
||||
skipped += 1
|
||||
|
||||
table.add_row(cat.name, repo.name, str(age_days), f"{size_mb:.1f}", action)
|
||||
|
||||
console.print(table)
|
||||
console.print(f"\n[green]✓ Clean abgeschlossen[/] – {deleted} gelöscht, {skipped} übersprungen")
|
80
src/repocat/cli/init.py
Normal file
80
src/repocat/cli/init.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from typer import Typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from pathlib import Path
|
||||
from rich.prompt import Confirm
|
||||
|
||||
from repocat.models.config import RepoCatConfig, RepoCategory
|
||||
from repocat.config import DEFAULT_CONFIG_PATH
|
||||
|
||||
app = Typer()
|
||||
console = Console()
|
||||
|
||||
# Standard-Konfigurationswert
|
||||
DEFAULT_CONFIG = RepoCatConfig(
|
||||
base_dir=Path("~/Repositories").expanduser(),
|
||||
categories=[
|
||||
RepoCategory(name="stable", subdir="stable", description="Langfristige Projekte"),
|
||||
RepoCategory(name="experiments", subdir="experiments", days=14, description="Kurzzeitige Tests"),
|
||||
RepoCategory(name="mirrors", subdir="mirrors", days=7),
|
||||
RepoCategory(name="review", subdir="review", days=30),
|
||||
RepoCategory(name="scratches", subdir="scratches", volatile=True),
|
||||
RepoCategory(name="archive", subdir="archive", is_archive=True, description="Archivierte Repos"),
|
||||
],
|
||||
)
|
||||
|
||||
@app.command("sync")
|
||||
def sync_directories(confirm_create: bool = True):
|
||||
"""
|
||||
Zeigt die geplante Ordnerstruktur an und legt sie optional an.
|
||||
"""
|
||||
config = DEFAULT_CONFIG # oder: load_config() bei existierender Datei
|
||||
base = config.base_dir
|
||||
|
||||
table = Table(title="Geplante Ordnerstruktur")
|
||||
table.add_column("Kategorie")
|
||||
table.add_column("Pfad")
|
||||
table.add_column("Status")
|
||||
|
||||
planned = []
|
||||
|
||||
for category in config.categories:
|
||||
path = base / category.subdir
|
||||
if path.exists():
|
||||
status = "[cyan]Vorhanden[/]"
|
||||
else:
|
||||
status = "[yellow]Fehlt[/]"
|
||||
planned.append(path)
|
||||
table.add_row(category.name, str(path), status)
|
||||
|
||||
console.print(table)
|
||||
|
||||
if not planned:
|
||||
console.print("[green]Alle benötigten Ordner sind bereits vorhanden.[/]")
|
||||
return
|
||||
|
||||
if Confirm.ask(f"[bold yellow]Möchtest du die fehlenden Ordner ([cyan]{len(planned)}[/]) erstellen?[/]"):
|
||||
for path in planned:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
console.print(f"[green]✓ Angelegt:[/] {path}")
|
||||
else:
|
||||
console.print("[yellow]Keine Änderungen vorgenommen.[/]")
|
||||
|
||||
|
||||
@app.command("create")
|
||||
def create_config():
|
||||
"""
|
||||
Erstellt eine neue Konfiguration unter ~/.repocat.yml
|
||||
"""
|
||||
if DEFAULT_CONFIG_PATH.exists():
|
||||
console.print(f"[yellow]Konfiguration existiert bereits:[/] {DEFAULT_CONFIG_PATH}")
|
||||
return
|
||||
|
||||
with open(DEFAULT_CONFIG_PATH, "w") as f:
|
||||
yaml_data = DEFAULT_CONFIG.model_dump(mode="json")
|
||||
import yaml
|
||||
yaml.dump(yaml_data, f)
|
||||
|
||||
console.print(f"[green]Standardkonfiguration erstellt unter:[/] {DEFAULT_CONFIG_PATH}")
|
||||
|
||||
sync_directories()
|
81
src/repocat/cli/move.py
Normal file
81
src/repocat/cli/move.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from typer import Typer, Argument
|
||||
from rich.console import Console
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import typer
|
||||
|
||||
from repocat.config import load_config
|
||||
|
||||
app = Typer()
|
||||
console = Console()
|
||||
|
||||
|
||||
def complete_source_repo(incomplete: str):
|
||||
config = load_config()
|
||||
base = config.base_dir
|
||||
results = []
|
||||
|
||||
for cat in config.categories:
|
||||
if cat.is_archive:
|
||||
continue
|
||||
cat_path = base / cat.subdir
|
||||
if not cat_path.exists():
|
||||
continue
|
||||
for item in cat_path.iterdir():
|
||||
if item.is_dir():
|
||||
full_name = f"{cat.name}/{item.name}"
|
||||
if full_name.startswith(incomplete):
|
||||
results.append(full_name)
|
||||
return sorted(results)
|
||||
|
||||
|
||||
def complete_category(incomplete: str):
|
||||
config = load_config()
|
||||
return [c.name for c in config.categories if c.name.startswith(incomplete) and c.name != "archive"]
|
||||
|
||||
|
||||
@app.command("move")
|
||||
def move_command(
|
||||
source: str = Argument(..., help="Quell-Repo im Format <category>/<reponame>", autocompletion=complete_source_repo),
|
||||
target: str = Argument(..., help="Zielkategorie", autocompletion=complete_category),
|
||||
):
|
||||
"""
|
||||
Verschiebt ein Repository in eine andere Kategorie.
|
||||
"""
|
||||
config = load_config()
|
||||
base = config.base_dir
|
||||
|
||||
# Quelle aufspalten in Kategorie und Repo
|
||||
try:
|
||||
source_cat_name, repo_name = source.split("/", 1)
|
||||
except ValueError:
|
||||
console.print("[red]Ungültiges Format für Quelle. Bitte <kategorie>/<reponame> angeben.[/]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
source_cat = next((c for c in config.categories if c.name == source_cat_name), None)
|
||||
if not source_cat:
|
||||
console.print(f"[red]Unbekannte Quellkategorie:[/] {source_cat_name}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
target_cat = next((c for c in config.categories if c.name == target), None)
|
||||
if not target_cat:
|
||||
console.print(f"[red]Ungültige Zielkategorie:[/] {target}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
source_path = base / source_cat.subdir / repo_name
|
||||
destination_path = base / target_cat.subdir / repo_name
|
||||
|
||||
if not source_path.exists():
|
||||
console.print(f"[red]Quell-Repository existiert nicht:[/] {source_path}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if destination_path.exists():
|
||||
console.print(f"[red]Zielordner existiert bereits:[/] {destination_path}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
try:
|
||||
shutil.move(str(source_path), str(destination_path))
|
||||
console.print(f"[green]✓ Erfolgreich verschoben:[/] {source_cat.name}/{repo_name} → {target}")
|
||||
except Exception as e:
|
||||
console.print(f"[red]Fehler beim Verschieben:[/] {e}")
|
||||
raise typer.Exit(1)
|
102
src/repocat/cli/review.py
Normal file
102
src/repocat/cli/review.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from typer import Typer, confirm, prompt
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
import typer
|
||||
from rich.prompt import Prompt, Confirm
|
||||
|
||||
from repocat.cli.move import move_command
|
||||
from repocat.config import load_config
|
||||
from repocat.models.config import RepoCategory
|
||||
from repocat.cli.archive import archive_repo
|
||||
|
||||
from repocat.cli.clean import get_dir_size, is_git_dirty, is_git_unpushed
|
||||
|
||||
app = Typer()
|
||||
console = Console()
|
||||
|
||||
|
||||
@app.command("run")
|
||||
def review():
|
||||
"""
|
||||
Führt interaktiv durch alle Repositories im Review-Ordner.
|
||||
"""
|
||||
config = load_config()
|
||||
base = config.base_dir
|
||||
|
||||
archive_cat = next((c for c in config.categories if c.is_archive), None)
|
||||
if not archive_cat:
|
||||
console.print("[red]Keine Archiv-Kategorie in der Konfiguration gefunden.[/]")
|
||||
raise typer.Exit(1)
|
||||
archive_path = base / archive_cat.subdir
|
||||
archive_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
review_cat = next((c for c in config.categories if c.name == "review"), None)
|
||||
if not review_cat:
|
||||
console.print("[red]Kein 'review'-Eintrag in der Konfiguration gefunden.[/]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
review_path = base / review_cat.subdir
|
||||
if not review_path.exists():
|
||||
console.print(f"[yellow]Hinweis:[/] Kein review-Ordner vorhanden unter {review_path}")
|
||||
return
|
||||
|
||||
repos = [p for p in review_path.iterdir() if p.is_dir()]
|
||||
if not repos:
|
||||
console.print("[green]✓ Keine Repositories im Review-Ordner.[/]")
|
||||
return
|
||||
|
||||
for repo in sorted(repos):
|
||||
console.rule(f"[bold cyan]{repo.name}")
|
||||
|
||||
age_days = (datetime.now() - datetime.fromtimestamp(repo.stat().st_mtime)).days
|
||||
size_mb = get_dir_size(repo) / 1024 / 1024
|
||||
|
||||
status = []
|
||||
if is_git_dirty(repo):
|
||||
status.append("uncommitted")
|
||||
if is_git_unpushed(repo):
|
||||
status.append("unpushed")
|
||||
if not status:
|
||||
status.append("clean")
|
||||
|
||||
console.print(f"[bold]Alter:[/] {age_days} Tage")
|
||||
console.print(f"[bold]Größe:[/] {size_mb:.1f} MB")
|
||||
console.print(f"[bold]Git:[/] {', '.join(status)}")
|
||||
|
||||
while True:
|
||||
action = Prompt.ask(
|
||||
"Aktion \\[m]ove / \\[a]rchive / \\[d]elete / \\[s]kip / \\[q]uit",
|
||||
choices=["m", "a", "d", "s", "q"],
|
||||
show_choices=False
|
||||
)
|
||||
|
||||
if action in ("s", "skip", ""):
|
||||
break
|
||||
elif action in ("q", "quit"):
|
||||
console.print("[bold yellow]Abgebrochen.[/]")
|
||||
raise typer.Exit()
|
||||
elif action in ("d", "delete"):
|
||||
if Confirm.ask(f"Bist du sicher, dass du [red]{repo.name}[/] löschen willst?"):
|
||||
shutil.rmtree(repo)
|
||||
console.print(f"[red]✓ Gelöscht:[/] {repo.name}")
|
||||
break
|
||||
elif action in ("m", "move"):
|
||||
target_categories = [c.name for c in config.categories if c.name != "review" and not c.is_archive]
|
||||
target = Prompt.ask("Zielkategorie", choices=target_categories)
|
||||
move_command(source=f"review/{repo.name}", target=target)
|
||||
break
|
||||
elif action in ("a", "archive"):
|
||||
try:
|
||||
archive_file = archive_repo(repo, archive_path, dry_run=False)
|
||||
console.print(f"[green]✓ Archiviert:[/] {archive_file.name}")
|
||||
break
|
||||
except Exception as e:
|
||||
console.print(f"[red]Fehler beim Archivieren:[/] {e}")
|
||||
else:
|
||||
console.print("[yellow]Ungültige Eingabe. Gültig sind: m, d, s, q[/]")
|
||||
|
||||
console.print("[green]✓ Review abgeschlossen.[/]")
|
75
src/repocat/cli/status.py
Normal file
75
src/repocat/cli/status.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from typer import Typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from repocat.config import load_config
|
||||
from repocat.models.config import RepoCategory
|
||||
|
||||
app = Typer()
|
||||
console = Console()
|
||||
|
||||
|
||||
def get_dir_size(path: Path) -> int:
|
||||
"""Returns size in bytes."""
|
||||
return sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
|
||||
|
||||
|
||||
def summarize_category(category: RepoCategory, base_path: Path):
|
||||
cat_path = base_path / category.subdir
|
||||
if not cat_path.exists():
|
||||
return 0, 0.0, None, None
|
||||
|
||||
now = datetime.now()
|
||||
repo_infos = []
|
||||
|
||||
for repo in cat_path.iterdir():
|
||||
if not repo.is_dir():
|
||||
continue
|
||||
|
||||
size_bytes = get_dir_size(repo)
|
||||
mtime = datetime.fromtimestamp(repo.stat().st_mtime)
|
||||
age_days = (now - mtime).days
|
||||
|
||||
repo_infos.append((repo.name, size_bytes, age_days, mtime))
|
||||
|
||||
if not repo_infos:
|
||||
return 0, 0.0, None, None
|
||||
|
||||
total_size = sum(info[1] for info in repo_infos) / 1024 / 1024
|
||||
avg_age = sum(info[2] for info in repo_infos) // len(repo_infos)
|
||||
|
||||
oldest = max(repo_infos, key=lambda x: x[2]) # größtes Alter
|
||||
|
||||
return len(repo_infos), total_size, avg_age, (oldest[0], oldest[2])
|
||||
|
||||
|
||||
@app.command("status")
|
||||
def status():
|
||||
"""
|
||||
Zeigt eine Übersicht über alle Repository-Kategorien.
|
||||
"""
|
||||
config = load_config()
|
||||
base = config.base_dir
|
||||
|
||||
table = Table(title="Repository-Status")
|
||||
table.add_column("Kategorie")
|
||||
table.add_column("Repos", justify="right")
|
||||
table.add_column("Gesamtgröße", justify="right")
|
||||
table.add_column("Ø Alter", justify="right")
|
||||
table.add_column("Ältestes Repo", justify="left")
|
||||
|
||||
for cat in config.categories:
|
||||
if cat.is_archive:
|
||||
continue
|
||||
count, total_size, avg_age, oldest = summarize_category(cat, base)
|
||||
|
||||
table.add_row(
|
||||
cat.name,
|
||||
str(count),
|
||||
f"{total_size:.1f} MB",
|
||||
f"{avg_age or '–'} d",
|
||||
f"{oldest[0]} ({oldest[1]} d)" if oldest else "–"
|
||||
)
|
||||
|
||||
console.print(table)
|
31
src/repocat/config.py
Normal file
31
src/repocat/config.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from repocat.models.config import RepoCatConfig
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
DEFAULT_CONFIG_PATH = Path.home() / ".repocat.yml"
|
||||
FALLBACK_PATH = Path("config.yml") # Optional, falls keine globale Config vorhanden
|
||||
|
||||
def load_config(file_path: Path | None = None) -> RepoCatConfig:
|
||||
config_path = file_path or DEFAULT_CONFIG_PATH
|
||||
|
||||
if not config_path.exists():
|
||||
console.print(f"[yellow]Warnung:[/] Konfigurationsdatei nicht gefunden: {config_path}")
|
||||
if FALLBACK_PATH.exists():
|
||||
console.print(f"[blue]Versuche stattdessen:[/] {FALLBACK_PATH}")
|
||||
config_path = FALLBACK_PATH
|
||||
else:
|
||||
raise FileNotFoundError(f"Keine Konfigurationsdatei gefunden in {config_path} oder {FALLBACK_PATH}")
|
||||
|
||||
try:
|
||||
with open(config_path, "r") as f:
|
||||
raw = yaml.safe_load(f)
|
||||
|
||||
config = RepoCatConfig(**raw)
|
||||
config.base_dir = Path(config.base_dir).expanduser()
|
||||
return config
|
||||
except Exception as e:
|
||||
console.print(f"[red]Fehler beim Laden der Konfiguration:[/] {e}")
|
||||
raise
|
20
src/repocat/models/config.py
Normal file
20
src/repocat/models/config.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
class RepoCategory(BaseModel):
|
||||
name: str
|
||||
subdir: str
|
||||
days: Optional[int] = Field(default=None, ge=1)
|
||||
volatile: bool = False
|
||||
description: Optional[str] = None
|
||||
is_archive: bool = False
|
||||
|
||||
class RepoCatDefaults(BaseModel):
|
||||
dry_run: bool = True
|
||||
auto_create_directories: bool = True
|
||||
|
||||
class RepoCatConfig(BaseModel):
|
||||
base_dir: Path = Field(default=Path("~/Repositories").expanduser())
|
||||
categories: List[RepoCategory]
|
||||
defaults: Optional[RepoCatDefaults] = RepoCatDefaults()
|
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
Reference in New Issue
Block a user