feat(models): add catalog and filesystem utilities
- Introduce models for managing repository catalog and categories - Add utilities for directory size and Git status checks - Enable repository metadata management, validation, and operations
This commit is contained in:
118
src/repocat/models/catalog.py
Normal file
118
src/repocat/models/catalog.py
Normal file
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel, Field
|
||||
import shutil
|
||||
|
||||
from repocat.models.config import RepoCategory, RepoCatConfig
|
||||
from repocat.utils.fsutils import get_dir_size, is_git_dirty, is_git_unpushed
|
||||
|
||||
|
||||
class GitStatus(BaseModel):
|
||||
dirty: bool
|
||||
unpushed: bool
|
||||
branch: Optional[str] = None
|
||||
remote_url: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_protected(self) -> bool:
|
||||
return self.dirty or self.unpushed
|
||||
|
||||
|
||||
class RepoModel(BaseModel):
|
||||
name: str
|
||||
path: Path
|
||||
size_bytes: int
|
||||
last_modified: datetime
|
||||
git: Optional[GitStatus] = None
|
||||
|
||||
@property
|
||||
def size_mb(self) -> float:
|
||||
return self.size_bytes / 1024 / 1024
|
||||
|
||||
@property
|
||||
def age_days(self) -> int:
|
||||
return (datetime.now() - self.last_modified).days
|
||||
|
||||
@property
|
||||
def is_git_repo(self) -> bool:
|
||||
return self.git is not None
|
||||
|
||||
def is_expired(self, days: int) -> bool:
|
||||
return self.age_days >= days
|
||||
|
||||
def move_to(self, target_dir: Path) -> None:
|
||||
destination = target_dir / self.name
|
||||
if destination.exists():
|
||||
raise FileExistsError(f"Zielordner existiert bereits: {destination}")
|
||||
shutil.move(str(self.path), str(destination))
|
||||
self.path = destination
|
||||
|
||||
|
||||
class RepoCategoryState(BaseModel):
|
||||
config: RepoCategory
|
||||
path: Path
|
||||
repos: List[RepoModel] = Field(default_factory=list)
|
||||
|
||||
def total_size_mb(self) -> float:
|
||||
return sum(repo.size_mb for repo in self.repos)
|
||||
|
||||
def find_repo(self, name: str) -> Optional[RepoModel]:
|
||||
return next((r for r in self.repos if r.name == name), None)
|
||||
|
||||
|
||||
class RepoCatalogState(BaseModel):
|
||||
config: RepoCatConfig
|
||||
categories: List[RepoCategoryState] = Field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config: RepoCatConfig) -> RepoCatalogState:
|
||||
categories: List[RepoCategoryState] = []
|
||||
|
||||
for cat_cfg in config.categories:
|
||||
cat_path = config.base_dir / cat_cfg.subdir
|
||||
repos: List[RepoModel] = []
|
||||
|
||||
if cat_path.exists():
|
||||
for item in sorted(cat_path.iterdir()):
|
||||
if not item.is_dir():
|
||||
continue
|
||||
|
||||
last_mod = datetime.fromtimestamp(item.stat().st_mtime)
|
||||
size = get_dir_size(item)
|
||||
|
||||
git = None
|
||||
if (item / ".git").exists():
|
||||
git = GitStatus(
|
||||
dirty=is_git_dirty(item),
|
||||
unpushed=is_git_unpushed(item),
|
||||
)
|
||||
|
||||
repos.append(RepoModel(
|
||||
name=item.name,
|
||||
path=item,
|
||||
size_bytes=size,
|
||||
last_modified=last_mod,
|
||||
git=git,
|
||||
))
|
||||
|
||||
categories.append(RepoCategoryState(
|
||||
config=cat_cfg,
|
||||
path=cat_path,
|
||||
repos=repos,
|
||||
))
|
||||
|
||||
return cls(config=config, categories=categories)
|
||||
|
||||
def get_category(self, name: str) -> Optional[RepoCategoryState]:
|
||||
return next((c for c in self.categories if c.config.name == name), None)
|
||||
|
||||
def find_repo(self, name: str) -> Optional[RepoModel]:
|
||||
for cat in self.categories:
|
||||
if (repo := cat.find_repo(name)):
|
||||
return repo
|
||||
return None
|
||||
|
||||
def missing_directories(self) -> List[Path]:
|
||||
return [cat.path for cat in self.categories if not cat.path.exists()]
|
43
src/repocat/utils/fsutils.py
Normal file
43
src/repocat/utils/fsutils.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
def get_dir_size(path: Path) -> int:
|
||||
"""Returns directory size in bytes."""
|
||||
return sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
|
||||
|
||||
def is_git_dirty(path: Path) -> bool:
|
||||
"""Returns True if there are uncommitted changes."""
|
||||
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:
|
||||
"""Returns True if there are commits that haven't been pushed to upstream."""
|
||||
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
|
Reference in New Issue
Block a user