mattzh72--articraft
301 行
9.0 KiB
Python
301 行
9.0 KiB
Python
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
from storage.repo import StorageRepo
|
|
from storage.revisions import active_model_path
|
|
|
|
|
|
def canonicalize_record_author(value: str | None) -> str | None:
|
|
text = str(value or "").strip()
|
|
if not text:
|
|
return None
|
|
return text
|
|
|
|
|
|
def resolve_current_record_author(repo_root: Path) -> str | None:
|
|
ident_result = subprocess.run(
|
|
["git", "var", "GIT_AUTHOR_IDENT"],
|
|
cwd=repo_root,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
ident_name = canonicalize_record_author(ident_result.stdout.partition("<")[0].strip())
|
|
if ident_name is not None:
|
|
return ident_name
|
|
|
|
config_result = subprocess.run(
|
|
["git", "config", "--get", "user.name"],
|
|
cwd=repo_root,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
return canonicalize_record_author(config_result.stdout.strip())
|
|
|
|
|
|
def _run_git(repo_root: Path, *args: str) -> str:
|
|
result = subprocess.run(
|
|
["git", *args],
|
|
cwd=repo_root,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if result.returncode != 0:
|
|
message = result.stderr.strip() or result.stdout.strip() or "git command failed"
|
|
raise RuntimeError(message)
|
|
return result.stdout
|
|
|
|
|
|
def _ensure_git_repo(repo_root: Path) -> None:
|
|
_run_git(repo_root, "rev-parse", "--show-toplevel")
|
|
|
|
|
|
def _candidate_record_ids(
|
|
repo: StorageRepo,
|
|
record_ids: list[str] | None,
|
|
) -> list[str]:
|
|
if record_ids is None:
|
|
records_root = repo.layout.records_root
|
|
if not records_root.exists():
|
|
return []
|
|
return sorted(
|
|
path.name
|
|
for path in records_root.iterdir()
|
|
if path.is_dir() and repo.layout.record_metadata_path(path.name).is_file()
|
|
)
|
|
return sorted(record_ids)
|
|
|
|
|
|
def _record_model_path(repo: StorageRepo, record_id: str, record: dict) -> Path:
|
|
return active_model_path(repo, record_id, record=record)
|
|
|
|
|
|
def _pathspec_for_repo_path(repo_root: Path, path: Path) -> str:
|
|
try:
|
|
return path.resolve().relative_to(repo_root.resolve()).as_posix()
|
|
except ValueError:
|
|
return path.as_posix()
|
|
|
|
|
|
def _creator_author_by_pathspec(repo_root: Path) -> dict[str, str]:
|
|
output = _run_git(
|
|
repo_root,
|
|
"log",
|
|
"--format=__AUTHOR__%an",
|
|
"--name-only",
|
|
"--diff-filter=A",
|
|
"--",
|
|
"data/records",
|
|
)
|
|
author_by_pathspec: dict[str, str] = {}
|
|
current_author: str | None = None
|
|
for raw_line in output.splitlines():
|
|
line = raw_line.strip()
|
|
if raw_line.startswith("__AUTHOR__"):
|
|
current_author = canonicalize_record_author(raw_line[len("__AUTHOR__") :])
|
|
continue
|
|
if not line or current_author is None:
|
|
continue
|
|
author_by_pathspec.setdefault(line, current_author)
|
|
return author_by_pathspec
|
|
|
|
|
|
def model_file_creator_author(repo_root: Path, model_path: Path) -> str | None:
|
|
pathspec = _pathspec_for_repo_path(repo_root, model_path)
|
|
output = _run_git(
|
|
repo_root,
|
|
"log",
|
|
"--follow",
|
|
"--diff-filter=A",
|
|
"--format=%an",
|
|
"--",
|
|
pathspec,
|
|
)
|
|
for line in output.splitlines():
|
|
author = canonicalize_record_author(line)
|
|
if author is not None:
|
|
return author
|
|
return None
|
|
|
|
|
|
def _record_json_line_author(
|
|
repo_root: Path,
|
|
record_path: Path,
|
|
*,
|
|
field_name: str,
|
|
) -> tuple[str | None, bool]:
|
|
pathspec = _pathspec_for_repo_path(repo_root, record_path)
|
|
output = _run_git(
|
|
repo_root,
|
|
"blame",
|
|
"HEAD",
|
|
"--line-porcelain",
|
|
"--",
|
|
pathspec,
|
|
)
|
|
|
|
current_author: str | None = None
|
|
field_prefix = f'"{field_name}":'
|
|
for raw_line in output.splitlines():
|
|
if raw_line.startswith("author "):
|
|
current_author = canonicalize_record_author(raw_line[len("author ") :])
|
|
continue
|
|
if not raw_line.startswith("\t"):
|
|
continue
|
|
|
|
line = raw_line[1:].lstrip()
|
|
if line.startswith(field_prefix):
|
|
return current_author, True
|
|
current_author = None
|
|
|
|
return None, False
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class RecordAuthorSyncSummary:
|
|
scanned: int = 0
|
|
updated_record_ids: list[str] = field(default_factory=list)
|
|
already_set_record_ids: list[str] = field(default_factory=list)
|
|
missing_record_ids: list[str] = field(default_factory=list)
|
|
missing_model_record_ids: list[str] = field(default_factory=list)
|
|
missing_git_author_record_ids: list[str] = field(default_factory=list)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class RecordRatedBySyncSummary:
|
|
scanned: int = 0
|
|
updated_record_ids: list[str] = field(default_factory=list)
|
|
unchanged_record_ids: list[str] = field(default_factory=list)
|
|
missing_record_ids: list[str] = field(default_factory=list)
|
|
missing_rating_line_record_ids: list[str] = field(default_factory=list)
|
|
missing_git_author_record_ids: list[str] = field(default_factory=list)
|
|
|
|
|
|
def _sync_record_line_author_field(
|
|
repo: StorageRepo,
|
|
*,
|
|
record_ids: list[str] | None,
|
|
rating_field: str,
|
|
rated_by_field: str,
|
|
clear_when_rating_is_null: bool = False,
|
|
) -> RecordRatedBySyncSummary:
|
|
repo.ensure_layout()
|
|
_ensure_git_repo(repo.root)
|
|
|
|
candidate_record_ids = _candidate_record_ids(repo, record_ids)
|
|
summary = RecordRatedBySyncSummary(scanned=len(candidate_record_ids))
|
|
for record_id in candidate_record_ids:
|
|
record_path = repo.layout.record_metadata_path(record_id)
|
|
record = repo.read_json(record_path)
|
|
if not isinstance(record, dict):
|
|
summary.missing_record_ids.append(record_id)
|
|
continue
|
|
|
|
existing_rated_by = canonicalize_record_author(record.get(rated_by_field))
|
|
if clear_when_rating_is_null and record.get(rating_field) is None:
|
|
if existing_rated_by is None:
|
|
summary.unchanged_record_ids.append(record_id)
|
|
continue
|
|
record[rated_by_field] = None
|
|
repo.write_json(record_path, record)
|
|
summary.updated_record_ids.append(record_id)
|
|
continue
|
|
|
|
try:
|
|
rated_by, found_rating_line = _record_json_line_author(
|
|
repo.root,
|
|
record_path,
|
|
field_name=rating_field,
|
|
)
|
|
except RuntimeError:
|
|
summary.missing_git_author_record_ids.append(record_id)
|
|
continue
|
|
|
|
if not found_rating_line:
|
|
summary.missing_rating_line_record_ids.append(record_id)
|
|
continue
|
|
if rated_by is None:
|
|
summary.missing_git_author_record_ids.append(record_id)
|
|
continue
|
|
if existing_rated_by == rated_by:
|
|
summary.unchanged_record_ids.append(record_id)
|
|
continue
|
|
|
|
record[rated_by_field] = rated_by
|
|
repo.write_json(record_path, record)
|
|
summary.updated_record_ids.append(record_id)
|
|
|
|
return summary
|
|
|
|
|
|
def sync_record_authors(
|
|
repo: StorageRepo,
|
|
*,
|
|
record_ids: list[str] | None = None,
|
|
) -> RecordAuthorSyncSummary:
|
|
repo.ensure_layout()
|
|
_ensure_git_repo(repo.root)
|
|
|
|
record_ids = _candidate_record_ids(repo, record_ids)
|
|
author_by_pathspec = _creator_author_by_pathspec(repo.root)
|
|
summary = RecordAuthorSyncSummary(scanned=len(record_ids))
|
|
for record_id in record_ids:
|
|
record_path = repo.layout.record_metadata_path(record_id)
|
|
record = repo.read_json(record_path)
|
|
if not isinstance(record, dict):
|
|
summary.missing_record_ids.append(record_id)
|
|
continue
|
|
|
|
existing_author = record.get("author")
|
|
if canonicalize_record_author(existing_author) is not None:
|
|
summary.already_set_record_ids.append(record_id)
|
|
continue
|
|
|
|
model_path = _record_model_path(repo, record_id, record)
|
|
if not model_path.is_file():
|
|
summary.missing_model_record_ids.append(record_id)
|
|
continue
|
|
|
|
author = author_by_pathspec.get(_pathspec_for_repo_path(repo.root, model_path))
|
|
if author is None:
|
|
summary.missing_git_author_record_ids.append(record_id)
|
|
continue
|
|
|
|
record["author"] = author
|
|
repo.write_json(record_path, record)
|
|
summary.updated_record_ids.append(record_id)
|
|
|
|
return summary
|
|
|
|
|
|
def sync_record_rated_by(
|
|
repo: StorageRepo,
|
|
*,
|
|
record_ids: list[str] | None = None,
|
|
) -> RecordRatedBySyncSummary:
|
|
return _sync_record_line_author_field(
|
|
repo,
|
|
record_ids=record_ids,
|
|
rating_field="rating",
|
|
rated_by_field="rated_by",
|
|
)
|
|
|
|
|
|
def sync_record_secondary_rated_by(
|
|
repo: StorageRepo,
|
|
*,
|
|
record_ids: list[str] | None = None,
|
|
) -> RecordRatedBySyncSummary:
|
|
return _sync_record_line_author_field(
|
|
repo,
|
|
record_ids=record_ids,
|
|
rating_field="secondary_rating",
|
|
rated_by_field="secondary_rated_by",
|
|
clear_when_rating_is_null=True,
|
|
)
|