🚧 WIP
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""git-auto-tracker: unattended Git change tracker for SCADA projects."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Allow `python -m git_auto_tracker`."""
|
||||
|
||||
import sys
|
||||
|
||||
from git_auto_tracker.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Application controller (Section 3): orchestrates one collection cycle.
|
||||
|
||||
Implements the processing sequence from Section 40.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from git_auto_tracker.audit import new_ulid
|
||||
from git_auto_tracker.authors.resolver import ResolverChain, to_identity
|
||||
from git_auto_tracker.changes.filters import filter_change_set
|
||||
from git_auto_tracker.changes.models import (
|
||||
ChangeRecord,
|
||||
ChangeSet,
|
||||
ChangeType,
|
||||
TrackerRun,
|
||||
)
|
||||
from git_auto_tracker.commits.commit import CommitManager
|
||||
from git_auto_tracker.commits.message_builder import build_commit_message
|
||||
from git_auto_tracker.config import AppConfig
|
||||
from git_auto_tracker.errors import RepositoryError
|
||||
from git_auto_tracker.git.repository import GitCommand
|
||||
from git_auto_tracker.git.status import ChangeDetector, RawStatusEntry
|
||||
from git_auto_tracker.logging.setup import get_logger
|
||||
from git_auto_tracker.security.validator import SecurityValidator
|
||||
|
||||
_APP_VERSION = "1.0.0"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CollectionOutcome:
|
||||
"""Result of one run: either a commit hash or an explicit no-changes state."""
|
||||
|
||||
commit_hash: str | None
|
||||
change_count: int
|
||||
commit_message: str | None
|
||||
dry_run: bool = False
|
||||
|
||||
@property
|
||||
def exit_code(self) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
class ApplicationController:
|
||||
"""Coordinates repository validation, detection, staging and commit."""
|
||||
|
||||
def __init__(self, config: AppConfig) -> None:
|
||||
self.config = config
|
||||
self._log = get_logger()
|
||||
self.git = GitCommand(config.repository_path)
|
||||
self.detector = ChangeDetector(self.git)
|
||||
self.security = SecurityValidator(config.security)
|
||||
self.resolvers = ResolverChain.from_config(config.author_resolution)
|
||||
self.committer = CommitManager(self.git)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Validation (Sections 5 and 5.1)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def validate_repository(self) -> str:
|
||||
"""Verify repository existence, branch, and index state."""
|
||||
path = self.config.repository_path
|
||||
if not path.is_dir():
|
||||
raise RepositoryError(f"repository path does not exist: {path}")
|
||||
if not (path / ".git").exists() and self.git.run(
|
||||
["rev-parse", "--git-dir"], check=False
|
||||
).returncode != 0:
|
||||
raise RepositoryError(f"not a git repository: {path}")
|
||||
|
||||
current = self.git.symbolic_ref()
|
||||
if current is not None and current != self.config.branch:
|
||||
raise RepositoryError(
|
||||
f"current branch {current!r} differs from configured branch "
|
||||
f"{self.config.branch!r} (Section 5.1)"
|
||||
)
|
||||
|
||||
# Unfinished merge/rebase state (Section 5.1)
|
||||
for state_file in ("MERGE_HEAD", "rebase-merge", "rebase-apply", "CHERRY_PICK_HEAD"):
|
||||
if (self.git.rev_parse_git_dir() / state_file).exists():
|
||||
raise RepositoryError(
|
||||
f"repository is in an unfinished {state_file} state (Section 5.1)"
|
||||
)
|
||||
|
||||
# Pre-existing staged changes (Section 5.1 / FR-026)
|
||||
staged = self.git.run_bytes(["diff", "--cached", "--name-only", "-z"])
|
||||
if any(part for part in staged.split(b"\x00") if part):
|
||||
raise RepositoryError("repository index already contains staged changes (FR-026)")
|
||||
|
||||
return current or "HEAD (detached)"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Change detection and ChangeSet assembly (Sections 6 and 12)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _records_from_status(self, entries: list[RawStatusEntry]) -> ChangeSet:
|
||||
from git_auto_tracker.git.status import snapshot_file_attributes
|
||||
|
||||
change_set = ChangeSet()
|
||||
root = self.config.repository_path
|
||||
for entry in entries:
|
||||
record = ChangeRecord(
|
||||
path=entry.path,
|
||||
change_type=entry.change_type,
|
||||
previous_path=entry.previous_path,
|
||||
)
|
||||
if entry.change_type is not ChangeType.DELETED:
|
||||
record.size, record.file_timestamp = self._file_attrs(
|
||||
entry.path, root, snapshot_file_attributes
|
||||
)
|
||||
bucket = {
|
||||
ChangeType.ADDED: change_set.added,
|
||||
ChangeType.MODIFIED: change_set.modified,
|
||||
ChangeType.DELETED: change_set.deleted,
|
||||
ChangeType.RENAMED: change_set.renamed,
|
||||
}[entry.change_type]
|
||||
bucket.append(record)
|
||||
return change_set
|
||||
|
||||
@staticmethod
|
||||
def _file_attrs(
|
||||
path: str,
|
||||
root: Path,
|
||||
snapshot: object,
|
||||
) -> tuple[int | None, dt.datetime | None]:
|
||||
from git_auto_tracker.git.status import snapshot_file_attributes as _snap
|
||||
|
||||
_ = snapshot
|
||||
attrs = _snap(path, root)
|
||||
if not attrs.get("exists"):
|
||||
return None, None
|
||||
ts = dt.datetime.fromtimestamp(attrs["mtime_ns"] / 1e9, tz=dt.UTC)
|
||||
return int(attrs["size"]), ts
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Collection (Section 40)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def collect(self, dry_run: bool = False) -> CollectionOutcome:
|
||||
"""Run one complete collection cycle; returns its outcome."""
|
||||
log = self._log
|
||||
log.info("Starting SCADA change collection")
|
||||
log.info("Repository: %s", self.config.repository_path)
|
||||
|
||||
branch = self.validate_repository()
|
||||
log.info("Branch: %s", branch)
|
||||
|
||||
entries = self.detector.detect()
|
||||
change_set = self._records_from_status(entries)
|
||||
|
||||
kept, excluded = filter_change_set(change_set, self.config.filters)
|
||||
if excluded:
|
||||
log.info("Excluded by filters: %d file(s)", len(excluded))
|
||||
|
||||
# Self-exclusion safety net (FR-028): operational artefacts are
|
||||
# already outside the repository; belt-and-braces path check here.
|
||||
self._assert_no_self_artifacts(kept)
|
||||
|
||||
if kept.is_empty:
|
||||
log.info("No SCADA changes detected.")
|
||||
return CollectionOutcome(None, 0, None)
|
||||
|
||||
self.security.validate(kept)
|
||||
|
||||
run = TrackerRun(
|
||||
run_id=new_ulid(),
|
||||
#started_at=dt.datetime.now(dt.UTC),
|
||||
started_at=dt.datetime.now().astimezone(),
|
||||
host=socket.gethostname(),
|
||||
)
|
||||
log.info("Detected %d changes (run %s)", len(kept), run.run_id)
|
||||
|
||||
for record in [*kept.added, *kept.modified]:
|
||||
result = self.resolvers.resolve(self.config.repository_path, record.path, log=log)
|
||||
record.author = to_identity(result)
|
||||
log.info(
|
||||
"Author resolved: %s -> %s", record.path, result.identifier
|
||||
)
|
||||
|
||||
message = build_commit_message(kept, run, app_version=_APP_VERSION)
|
||||
|
||||
if dry_run:
|
||||
log.info("Dry-run: repository left unmodified")
|
||||
return CollectionOutcome(None, len(kept), message, dry_run=True)
|
||||
|
||||
self.committer.stage_and_verify(kept)
|
||||
commit_hash = self.git.commit(
|
||||
message, self.config.git_user_name, self.config.git_user_email
|
||||
)
|
||||
log.info("Commit created: %s", commit_hash[:8])
|
||||
log.info("Collection completed")
|
||||
return CollectionOutcome(commit_hash, len(kept), message)
|
||||
|
||||
def _assert_no_self_artifacts(self, change_set: ChangeSet) -> None:
|
||||
"""Refuse to commit tracker operational artefacts (FR-028)."""
|
||||
suffixes = (".git-auto-tracker.lock",)
|
||||
for record in change_set.all_records():
|
||||
for suffix in suffixes:
|
||||
if record.path.endswith(suffix):
|
||||
raise RepositoryError(
|
||||
f"refusing to commit tracker artefact: {record.path} (FR-028)"
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""ULID generation (Section 35).
|
||||
|
||||
ULIDs are lexicographically sortable, timezone-independent identifiers
|
||||
linking logs, commits and monitoring. Implemented without an external
|
||||
dependency per the specification's "equivalent identifier" allowance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import secrets
|
||||
|
||||
_ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" # Crockford base32
|
||||
_RANDOM_LEN = 16
|
||||
_TIME_LEN = 10
|
||||
|
||||
|
||||
def _encode_time(timestamp_ms: int) -> str:
|
||||
value = max(timestamp_ms, 0)
|
||||
chars = [""] * _TIME_LEN
|
||||
for i in range(_TIME_LEN - 1, -1, -1):
|
||||
chars[i] = _ENCODING[value & 0x1F]
|
||||
value >>= 5
|
||||
return "".join(chars)
|
||||
|
||||
|
||||
def _encode_random(count: int) -> str:
|
||||
# 5 bits per char; gather enough random bytes then fold
|
||||
bits = count * 5
|
||||
nbytes = (bits + 7) // 8
|
||||
raw = int.from_bytes(secrets.token_bytes(nbytes), "big")
|
||||
chars = [""] * count
|
||||
for i in range(count - 1, -1, -1):
|
||||
chars[i] = _ENCODING[raw & 0x1F]
|
||||
raw >>= 5
|
||||
return "".join(chars)
|
||||
|
||||
|
||||
def new_ulid(timestamp: dt.datetime | None = None) -> str:
|
||||
"""Generate a ULID string, e.g. 01J7K8HYV84ZKFH1J4T9R8DZQN."""
|
||||
moment = timestamp or dt.datetime.now(dt.UTC)
|
||||
if moment.tzinfo is None:
|
||||
moment = moment.replace(tzinfo=dt.UTC)
|
||||
timestamp_ms = int(moment.timestamp() * 1000)
|
||||
return _encode_time(timestamp_ms) + _encode_random(_RANDOM_LEN)
|
||||
@@ -0,0 +1 @@
|
||||
"""Author resolution framework (Sections 9-11)."""
|
||||
@@ -0,0 +1,45 @@
|
||||
"""AuthorResolver interface and resolution result types (Sections 9-11)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from git_auto_tracker.changes.models import Confidence
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorResult:
|
||||
"""Outcome of one author-resolution attempt (Section 9)."""
|
||||
|
||||
identifier: str
|
||||
name: str | None = None
|
||||
source: str = "unknown"
|
||||
confidence: Confidence = Confidence.UNKNOWN
|
||||
timestamp: dt.datetime | None = None
|
||||
|
||||
@classmethod
|
||||
def unknown(cls, source: str, reason: str | None = None) -> AuthorResult:
|
||||
_ = reason # reason is surfaced through logs, not the result payload
|
||||
return cls(identifier="UNKNOWN", source=source, confidence=Confidence.UNKNOWN)
|
||||
|
||||
|
||||
class AuthorResolver(ABC):
|
||||
"""Pluggable last-modifier identification (Section 9).
|
||||
|
||||
Implementations must never raise for unavailable sources; they return a
|
||||
low-confidence / UNKNOWN result instead so the chain can continue
|
||||
(FR-021). name/source/confidence semantics follow Section 9.
|
||||
"""
|
||||
|
||||
#: Registry name used in configuration (author_resolution.resolvers)
|
||||
name: str = "unknown"
|
||||
|
||||
@abstractmethod
|
||||
def resolve(self, repository_root: Path, file_path: str) -> AuthorResult:
|
||||
"""Attempt to determine the last modifier of *file_path*.
|
||||
|
||||
*file_path* is repository-relative with POSIX separators.
|
||||
"""
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Resolver chain: priority order + confidence threshold + fallback (Section 11)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from git_auto_tracker.authors.base import AuthorResolver, AuthorResult
|
||||
from git_auto_tracker.authors.sidecar import SidecarMetadataResolver
|
||||
from git_auto_tracker.changes.models import AuthorIdentity, Confidence
|
||||
from git_auto_tracker.config import AuthorResolutionConfig
|
||||
from git_auto_tracker.errors import ConfigurationError
|
||||
|
||||
|
||||
def available_resolvers() -> dict[str, type[AuthorResolver]]:
|
||||
"""Built-in resolver registry keyed by configuration name."""
|
||||
return {SidecarMetadataResolver.name: SidecarMetadataResolver}
|
||||
|
||||
|
||||
class ResolverChain:
|
||||
"""Executes resolvers in configured order; first usable result wins."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resolvers: list[AuthorResolver],
|
||||
minimum_confidence: Confidence,
|
||||
fallback: AuthorResult,
|
||||
) -> None:
|
||||
self._resolvers = resolvers
|
||||
self._minimum = minimum_confidence
|
||||
self._fallback = fallback
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config: AuthorResolutionConfig) -> ResolverChain:
|
||||
registry = available_resolvers()
|
||||
resolvers: list[AuthorResolver] = []
|
||||
for name in config.resolvers:
|
||||
factory = registry.get(name)
|
||||
if factory is None:
|
||||
raise ConfigurationError(f"unknown author resolver: {name}")
|
||||
resolvers.append(factory())
|
||||
minimum = Confidence(config.minimum_confidence)
|
||||
if config.fallback not in ("unknown", "none"):
|
||||
raise ConfigurationError(f"unsupported fallback: {config.fallback}")
|
||||
fallback = AuthorResult.unknown("fallback")
|
||||
return cls(resolvers, minimum, fallback)
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
repository_root: Path,
|
||||
file_path: str,
|
||||
log: object | None = None,
|
||||
) -> AuthorResult:
|
||||
"""Run the chain for one file; results below threshold are ignored."""
|
||||
for resolver in self._resolvers:
|
||||
try:
|
||||
result = resolver.resolve(repository_root, file_path)
|
||||
except Exception: # noqa: BLE001 - resolvers must never break a commit
|
||||
if log is not None:
|
||||
log.warning("author resolver %s failed for %s", resolver.name, file_path)
|
||||
continue
|
||||
if result.confidence.at_least(self._minimum) and result.identifier != "UNKNOWN":
|
||||
return result
|
||||
return self._fallback
|
||||
|
||||
|
||||
def to_identity(result: AuthorResult) -> AuthorIdentity:
|
||||
"""Convert an AuthorResult into the ChangeRecord author field."""
|
||||
return AuthorIdentity(
|
||||
identifier=result.identifier,
|
||||
name=result.name,
|
||||
source=result.source,
|
||||
confidence=result.confidence,
|
||||
timestamp=result.timestamp,
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Sidecar metadata resolver (Section 10: companion metadata).
|
||||
|
||||
Reads author information from a sidecar file next to the tracked file:
|
||||
|
||||
pumpstation.xml
|
||||
pumpstation.xml.metadata
|
||||
|
||||
Supported sidecar formats (auto-detected):
|
||||
|
||||
* JSON: {"last_modified_by": "...", "last_modified_at": "...", "name": "..."}
|
||||
* KEY=VALUE lines: last_modified_by=DOMAIN\\jsmith
|
||||
|
||||
Missing or malformed sidecars yield UNKNOWN so the chain continues.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from git_auto_tracker.authors.base import AuthorResolver, AuthorResult
|
||||
from git_auto_tracker.changes.models import Confidence
|
||||
|
||||
_SUFFIX = ".metadata"
|
||||
_KEYS = ("last_modified_by", "last_modified_by_user", "modified_by", "user")
|
||||
_NAME_KEYS = ("last_modified_by_name", "name", "full_name")
|
||||
_TS_KEYS = ("last_modified_at", "last_modified_time", "modified_at", "timestamp")
|
||||
|
||||
|
||||
def _parse_iso(value: str) -> dt.datetime | None:
|
||||
try:
|
||||
parsed = dt.datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=dt.UTC)
|
||||
|
||||
|
||||
class SidecarMetadataResolver(AuthorResolver):
|
||||
"""Reads a `<file>.metadata` companion file (Section 10)."""
|
||||
|
||||
name = "sidecar_metadata"
|
||||
|
||||
def resolve(self, repository_root: Path, file_path: str) -> AuthorResult:
|
||||
sidecar = repository_root / Path(file_path).with_name(Path(file_path).name + _SUFFIX)
|
||||
try:
|
||||
raw = sidecar.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return AuthorResult.unknown(self.name, "sidecar not readable")
|
||||
|
||||
data = self._parse(raw)
|
||||
if data is None:
|
||||
return AuthorResult.unknown(self.name, "sidecar malformed")
|
||||
|
||||
identifier = self._first_present(data, _KEYS)
|
||||
if not identifier:
|
||||
return AuthorResult.unknown(self.name, "sidecar has no author field")
|
||||
|
||||
timestamp = None
|
||||
raw_ts = self._first_present(data, _TS_KEYS)
|
||||
if isinstance(raw_ts, str) and raw_ts:
|
||||
timestamp = _parse_iso(raw_ts)
|
||||
|
||||
return AuthorResult(
|
||||
identifier=str(identifier),
|
||||
name=self._optional_str(self._first_present(data, _NAME_KEYS)),
|
||||
source=self.name,
|
||||
confidence=Confidence.PROBABLE,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
def _parse(self, raw: str) -> dict[str, object] | None:
|
||||
stripped = raw.strip()
|
||||
if stripped.startswith("{"):
|
||||
try:
|
||||
data = json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
# KEY=VALUE lines
|
||||
result: dict[str, object] = {}
|
||||
for line in stripped.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
result[key.strip().lower()] = value.strip()
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _first_present(data: dict[str, object], keys: tuple[str, ...]) -> object | None:
|
||||
for key in keys:
|
||||
value = data.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
return value
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _optional_str(value: object | None) -> str | None:
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return None
|
||||
@@ -0,0 +1 @@
|
||||
"""Change detection and ChangeSet models (Sections 6 and 12)."""
|
||||
@@ -0,0 +1,150 @@
|
||||
"""File filter and security validation (Sections 7 and 33).
|
||||
|
||||
Pure-path matching: patterns are matched against the repository-relative
|
||||
POSIX path. Excluded files are neither staged nor committed nor reported.
|
||||
Forbidden files abort the commit with a security exit code by default.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from git_auto_tracker.changes.models import ChangeSet
|
||||
from git_auto_tracker.config import FileFilters, SecurityConfig
|
||||
from git_auto_tracker.errors import SecurityError
|
||||
|
||||
_PATTERN_CACHE: dict[str, re.Pattern[str]] = {}
|
||||
|
||||
|
||||
def matches(path: str, pattern: str) -> bool:
|
||||
"""Match a repository-relative POSIX path against a glob pattern.
|
||||
|
||||
Implements gitignore-style `**` semantics, which fnmatch lacks:
|
||||
`**/x` matches x at any depth including the root, `dir/**` matches
|
||||
everything under dir (and the directory itself), and `a/**/b`
|
||||
matches b at any depth under a.
|
||||
"""
|
||||
regex = _PATTERN_CACHE.get(pattern)
|
||||
if regex is None:
|
||||
regex = _compile(pattern)
|
||||
_PATTERN_CACHE[pattern] = regex
|
||||
if regex.fullmatch(path) is not None:
|
||||
return True
|
||||
# `dir/**` also matches the bare directory entry itself, so a status
|
||||
# line for the directory (or a file at that exact path) is excluded too
|
||||
if pattern.endswith("/**"):
|
||||
prefix = pattern[:-3]
|
||||
return path == prefix
|
||||
return False
|
||||
|
||||
|
||||
def _compile(pattern: str) -> re.Pattern[str]:
|
||||
"""Translate a glob pattern with gitignore-style `**` into a regex.
|
||||
|
||||
Segment rules:
|
||||
* `**` as the final segment matches everything inside the directory
|
||||
(gitignore: `dir/**` -> `dir/*`); the leading slash stays literal.
|
||||
* `**` followed by more segments matches zero or more directories:
|
||||
`**/x` matches `x` anywhere, `a/**/b` also matches `a/b`.
|
||||
* `*` and `?` inside a segment never cross `/`.
|
||||
"""
|
||||
segments = pattern.split("/")
|
||||
parts: list[str] = []
|
||||
last = len(segments) - 1
|
||||
for index, segment in enumerate(segments):
|
||||
if segment == "**":
|
||||
if index == last:
|
||||
parts.append(".*")
|
||||
else:
|
||||
# Zero or more directories; consumes the trailing slash
|
||||
parts.append("(?:[^/]+/)*")
|
||||
continue
|
||||
parts.append(_translate_segment(segment))
|
||||
if index < last:
|
||||
parts.append("/")
|
||||
return re.compile("(?s:" + "".join(parts) + r")\Z")
|
||||
|
||||
|
||||
def _translate_segment(segment: str) -> str:
|
||||
"""Translate one path segment (no `/` crossing) to a regex fragment."""
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
while i < len(segment):
|
||||
char = segment[i]
|
||||
if char == "*":
|
||||
out.append("[^/]*")
|
||||
elif char == "?":
|
||||
out.append("[^/]")
|
||||
elif char == "[":
|
||||
end = segment.find("]", i + 1)
|
||||
if end == -1:
|
||||
out.append(re.escape(char))
|
||||
else:
|
||||
content = segment[i + 1 : end]
|
||||
if content.startswith("!"):
|
||||
content = "^" + content[1:]
|
||||
out.append(f"[{content}]")
|
||||
i = end
|
||||
else:
|
||||
out.append(re.escape(char))
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def is_included(path: str, filters: FileFilters) -> bool:
|
||||
"""True when the path matches any include pattern (empty include = all)."""
|
||||
return not filters.include or any(matches(path, p) for p in filters.include)
|
||||
|
||||
|
||||
def is_excluded(path: str, filters: FileFilters) -> bool:
|
||||
return any(matches(path, p) for p in filters.exclude)
|
||||
|
||||
|
||||
def filter_change_set(
|
||||
change_set: ChangeSet,
|
||||
filters: FileFilters,
|
||||
) -> tuple[ChangeSet, list[str]]:
|
||||
"""Apply include/exclude rules to a ChangeSet.
|
||||
|
||||
Returns the filtered ChangeSet plus the list of excluded paths
|
||||
(for logging).
|
||||
"""
|
||||
from git_auto_tracker.changes.models import ChangeType
|
||||
|
||||
kept = ChangeSet()
|
||||
excluded: list[str] = []
|
||||
|
||||
buckets = {
|
||||
ChangeType.ADDED: (change_set.added, kept.added),
|
||||
ChangeType.MODIFIED: (change_set.modified, kept.modified),
|
||||
ChangeType.DELETED: (change_set.deleted, kept.deleted),
|
||||
ChangeType.RENAMED: (change_set.renamed, kept.renamed),
|
||||
}
|
||||
|
||||
for records, target in buckets.values():
|
||||
for record in records:
|
||||
path = record.previous_path or record.path
|
||||
if is_included(path, filters) and not is_excluded(path, filters):
|
||||
target.append(record)
|
||||
else:
|
||||
excluded.append(record.path)
|
||||
|
||||
return kept, excluded
|
||||
|
||||
|
||||
def find_forbidden(paths: list[str], security: SecurityConfig) -> list[str]:
|
||||
"""Return the paths matching any forbidden pattern (Section 33)."""
|
||||
return [p for p in paths if any(matches(p, pat) for pat in security.forbidden_patterns)]
|
||||
|
||||
|
||||
def validate_security(change_set: ChangeSet, security: SecurityConfig) -> list[str]:
|
||||
"""Find forbidden files in a ChangeSet; raises SecurityError when found."""
|
||||
paths = [r.path for r in change_set.all_records()]
|
||||
# For renames, also check the source path
|
||||
paths += [r.previous_path for r in change_set.renamed if r.previous_path]
|
||||
forbidden = find_forbidden(paths, security)
|
||||
if forbidden:
|
||||
raise SecurityError(
|
||||
"sensitive files detected, refusing to commit: " + ", ".join(sorted(forbidden))
|
||||
)
|
||||
return forbidden
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Domain models for the change tracker.
|
||||
|
||||
Implements the core objects from Section 39 of the functional specification:
|
||||
ChangeRecord, ChangeSet, AuthorIdentity, TrackerRun, CommitResult.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import enum
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ChangeType(enum.StrEnum):
|
||||
"""Type of a detected change (Section 6.1)."""
|
||||
|
||||
ADDED = "ADDED"
|
||||
MODIFIED = "MODIFIED"
|
||||
DELETED = "DELETED"
|
||||
RENAMED = "RENAMED"
|
||||
|
||||
|
||||
class Confidence(enum.StrEnum):
|
||||
"""Trustworthiness of an author-resolution result (Section 9)."""
|
||||
|
||||
AUTHORITATIVE = "authoritative"
|
||||
PROBABLE = "probable"
|
||||
WEAK = "weak"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
def at_least(self, other: Confidence) -> bool:
|
||||
"""True if this confidence meets or exceeds *other*."""
|
||||
order = [
|
||||
Confidence.UNKNOWN,
|
||||
Confidence.WEAK,
|
||||
Confidence.PROBABLE,
|
||||
Confidence.AUTHORITATIVE,
|
||||
]
|
||||
return order.index(self) >= order.index(other)
|
||||
@dataclass(frozen=True)
|
||||
class AuthorIdentity:
|
||||
"""Resolved identity of the last modifier of a file (Section 9)."""
|
||||
|
||||
identifier: str
|
||||
name: str | None = None
|
||||
source: str = "unknown"
|
||||
confidence: Confidence = Confidence.UNKNOWN
|
||||
timestamp: dt.datetime | None = None
|
||||
|
||||
UNKNOWN = object() # sentinel for classmethod below
|
||||
|
||||
@classmethod
|
||||
def unknown(cls, source: str = "unknown") -> AuthorIdentity:
|
||||
return cls(identifier="UNKNOWN", source=source, confidence=Confidence.UNKNOWN)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChangeRecord:
|
||||
"""A single detected change together with its metadata (Section 12)."""
|
||||
|
||||
path: str # repository-relative, POSIX separators
|
||||
change_type: ChangeType
|
||||
previous_path: str | None = None
|
||||
size: int | None = None
|
||||
file_timestamp: dt.datetime | None = None
|
||||
author: AuthorIdentity | None = None
|
||||
|
||||
@property
|
||||
def needs_author(self) -> bool:
|
||||
"""Only added and modified files require a last-modifier lookup."""
|
||||
return self.change_type in (ChangeType.ADDED, ChangeType.MODIFIED)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChangeSet:
|
||||
"""The complete set of changes detected during one collection (Section 39)."""
|
||||
|
||||
added: list[ChangeRecord] = field(default_factory=list)
|
||||
modified: list[ChangeRecord] = field(default_factory=list)
|
||||
deleted: list[ChangeRecord] = field(default_factory=list)
|
||||
renamed: list[ChangeRecord] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return not (self.added or self.modified or self.deleted or self.renamed)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.added) + len(self.modified) + len(self.deleted) + len(self.renamed)
|
||||
|
||||
def all_records(self) -> list[ChangeRecord]:
|
||||
return [*self.added, *self.modified, *self.deleted, *self.renamed]
|
||||
|
||||
def summary_counts(self) -> dict[str, int]:
|
||||
return {
|
||||
"added": len(self.added),
|
||||
"modified": len(self.modified),
|
||||
"deleted": len(self.deleted),
|
||||
"renamed": len(self.renamed),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrackerRun:
|
||||
"""One collection execution, identified by a ULID (Section 35)."""
|
||||
|
||||
run_id: str
|
||||
started_at: dt.datetime
|
||||
host: str
|
||||
|
||||
@property
|
||||
def trailer_lines(self) -> list[str]:
|
||||
return [f"Tracker-Run: {self.run_id}", f"Tracker-Host: {self.host}"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommitResult:
|
||||
"""Outcome of a commit attempt."""
|
||||
|
||||
commit_hash: str | None
|
||||
change_count: int
|
||||
pushed: bool = False
|
||||
|
||||
@classmethod
|
||||
def empty(cls) -> CommitResult:
|
||||
return cls(commit_hash=None, change_count=0)
|
||||
|
||||
|
||||
def repo_relpath(path: Path | str) -> str:
|
||||
"""Normalize a path to a repository-relative POSIX string."""
|
||||
return Path(path).as_posix()
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Command-line interface (Sections 30 and 31)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from git_auto_tracker import __version__
|
||||
from git_auto_tracker.application import ApplicationController
|
||||
from git_auto_tracker.config import load_config, redact
|
||||
from git_auto_tracker.errors import ExitCode, TrackerError
|
||||
from git_auto_tracker.git.repository import check_git_available, verify_git_version
|
||||
from git_auto_tracker.locking.repository_lock import RepositoryLock, default_lock_path
|
||||
from git_auto_tracker.logging.setup import setup_logging
|
||||
|
||||
_DEFAULT_CONFIG = "git-auto-tracker.yaml"
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="git-auto-tracker",
|
||||
description="Unattended Git change tracker for SCADA project files",
|
||||
)
|
||||
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
||||
parser.add_argument("--config", type=Path, default=Path(_DEFAULT_CONFIG))
|
||||
parser.add_argument("--log-level", choices=["DEBUG", "INFO", "WARNING", "ERROR"])
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
run_parser = subparsers.add_parser("run", help="collect changes and commit")
|
||||
run_parser.add_argument("--dry-run", action="store_true", help="do not modify the repository")
|
||||
|
||||
subparsers.add_parser("status", help="show detected changes without committing")
|
||||
subparsers.add_parser("validate", help="validate configuration, repository and resolvers")
|
||||
subparsers.add_parser("config", help="print the effective configuration (secrets redacted)")
|
||||
return parser
|
||||
|
||||
|
||||
def _print_changes(controller: ApplicationController) -> int:
|
||||
"""status command body: list detected changes without staging."""
|
||||
controller.validate_repository()
|
||||
entries = controller.detector.detect()
|
||||
if not entries:
|
||||
print("No changes detected.")
|
||||
return ExitCode.SUCCESS
|
||||
for entry in entries:
|
||||
marker = "R" if entry.change_type.value == "RENAMED" else entry.change_type.value[0]
|
||||
print(f"{marker} {entry.path}")
|
||||
return ExitCode.SUCCESS
|
||||
|
||||
|
||||
def _validate_command(controller: ApplicationController) -> int:
|
||||
"""validate command body: configuration, git, repository, resolvers."""
|
||||
version_line = check_git_available()
|
||||
if not verify_git_version(version_line):
|
||||
raise TrackerError(f"insufficient git version: {version_line}")
|
||||
branch = controller.validate_repository()
|
||||
print(f"OK git: {version_line}")
|
||||
print(f"OK repository: {controller.config.repository_path} (branch {branch})")
|
||||
for name in controller.config.author_resolution.resolvers:
|
||||
print(f"OK resolver: {name}")
|
||||
return ExitCode.SUCCESS
|
||||
|
||||
|
||||
def _config_command(config_path: Path) -> int:
|
||||
import yaml # local import keeps CLI startup light
|
||||
|
||||
from git_auto_tracker.config import load_config as _load
|
||||
|
||||
_load(config_path) # validates; raises ConfigurationError on problems
|
||||
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
print(yaml.safe_dump(redact(raw), sort_keys=True), end="")
|
||||
return ExitCode.SUCCESS
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
|
||||
try:
|
||||
config = load_config(args.config)
|
||||
logger = setup_logging(
|
||||
config.logging,
|
||||
config_dir=args.config.parent,
|
||||
repository_path=config.repository_path,
|
||||
override_level=args.log_level,
|
||||
)
|
||||
_ = logger
|
||||
|
||||
version_line = check_git_available()
|
||||
if not verify_git_version(version_line):
|
||||
raise TrackerError(f"insufficient git version: {version_line}")
|
||||
|
||||
if args.command == "config":
|
||||
return _config_command(args.config)
|
||||
|
||||
controller = ApplicationController(config)
|
||||
lock = RepositoryLock(default_lock_path(config.repository_path))
|
||||
|
||||
if args.command == "validate":
|
||||
return _validate_command(controller)
|
||||
|
||||
if args.command == "status":
|
||||
lock.acquire()
|
||||
try:
|
||||
return _print_changes(controller)
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
# run (Section 40)
|
||||
lock.acquire()
|
||||
try:
|
||||
outcome = controller.collect(dry_run=args.dry_run)
|
||||
except Exception:
|
||||
controller.committer.restore_index()
|
||||
raise
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
if outcome.dry_run and outcome.commit_message:
|
||||
print(outcome.commit_message)
|
||||
elif outcome.dry_run:
|
||||
print("No changes detected.")
|
||||
return ExitCode.SUCCESS
|
||||
|
||||
except TrackerError as exc:
|
||||
logging.getLogger("git_auto_tracker").error("%s", exc.message)
|
||||
return int(exc.exit_code)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logging.getLogger("git_auto_tracker").exception("internal error: %s", exc)
|
||||
return int(ExitCode.GENERAL_FAILURE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1 @@
|
||||
"""Commit message generation and staging/commit management (Sections 14-16, 36)."""
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Git staging and commit management (Sections 14 and 19).
|
||||
|
||||
Staging uses pathspec-limited `git add -A -- <paths>` so only selected
|
||||
paths are staged, including deletions. The staged change set is then
|
||||
verified against the expected one (Section 14, step 5) and the index is
|
||||
restored on mismatch.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from git_auto_tracker.changes.models import ChangeSet, ChangeType
|
||||
from git_auto_tracker.errors import GitError
|
||||
from git_auto_tracker.git.repository import GitCommand
|
||||
|
||||
_GIT_LETTER = {
|
||||
ChangeType.ADDED: "A",
|
||||
ChangeType.MODIFIED: "M",
|
||||
ChangeType.DELETED: "D",
|
||||
ChangeType.RENAMED: "R",
|
||||
}
|
||||
|
||||
|
||||
class CommitManager:
|
||||
"""Stages the expected change set, verifies it, and commits."""
|
||||
|
||||
def __init__(self, git: GitCommand) -> None:
|
||||
self.git = git
|
||||
|
||||
def _stage_paths(self, paths: list[str]) -> None:
|
||||
"""Stage additions/modifications/deletions for the given paths.
|
||||
|
||||
`git add -A -- <pathspec>` stages all worktree states for the
|
||||
selected pathspecs, including deletions, without touching other
|
||||
files (Section 14).
|
||||
"""
|
||||
if not paths:
|
||||
return
|
||||
# Split pathspec lists into safe batches; Windows command-line
|
||||
# length limits make one-invocation-per-repository risky.
|
||||
batch_size = 100
|
||||
for start in range(0, len(paths), batch_size):
|
||||
batch = paths[start : start + batch_size]
|
||||
self.git.run(["add", "-A", "--", *batch])
|
||||
|
||||
def _expected_status_map(self, change_set: ChangeSet) -> dict[str, str]:
|
||||
"""Map staged path -> expected git letter for verification.
|
||||
|
||||
RENAMED is expected as add+delete per Section 6.1, matching
|
||||
`git diff --cached --no-renames` output.
|
||||
"""
|
||||
mapping: dict[str, str] = {}
|
||||
for record in change_set.all_records():
|
||||
if record.change_type is ChangeType.RENAMED:
|
||||
mapping[record.previous_path or record.path] = "D"
|
||||
mapping[record.path] = "A"
|
||||
else:
|
||||
mapping[record.path] = _GIT_LETTER[record.change_type]
|
||||
return mapping
|
||||
|
||||
def stage_and_verify(self, change_set: ChangeSet) -> None:
|
||||
"""Stage exactly the ChangeSet; raise GitError on staged mismatch."""
|
||||
paths = sorted(
|
||||
{record.path for record in change_set.all_records()}
|
||||
| {
|
||||
record.previous_path
|
||||
for record in change_set.renamed
|
||||
if record.previous_path
|
||||
}
|
||||
)
|
||||
self._stage_paths(paths)
|
||||
|
||||
staged = self.git.commit_file_status()
|
||||
expected = self._expected_status_map(change_set)
|
||||
|
||||
actual = {path: status[0] for status, path, _previous in staged}
|
||||
|
||||
if actual != expected:
|
||||
missing = sorted(set(expected) - set(actual))
|
||||
extra = sorted(set(actual) - set(expected))
|
||||
raise GitError(
|
||||
"staged change set does not match expected set "
|
||||
f"(missing: {missing or 'none'}, unexpected: {extra or 'none'})"
|
||||
)
|
||||
|
||||
def restore_index(self) -> None:
|
||||
"""Best-effort restore of the index to its pre-execution state."""
|
||||
self.git.run(["reset", "--mixed", "HEAD"], check=False)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Commit message generation (Sections 15, 16, 36).
|
||||
|
||||
Human-readable sections first, Git trailer block last so that
|
||||
`git interpret-trailers` can extract the structured metadata.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
|
||||
from git_auto_tracker.changes.models import ChangeSet, TrackerRun
|
||||
|
||||
_SUMMARY_LABELS = (
|
||||
("Added:", "added"),
|
||||
("Modified:", "modified"),
|
||||
("Deleted:", "deleted"),
|
||||
("Renamed:", "renamed"),
|
||||
)
|
||||
|
||||
|
||||
def _format_ts(moment: dt.datetime | None) -> str:
|
||||
if moment is None:
|
||||
return ""
|
||||
return moment.strftime("%Y-%m-%d %H:%M:%S%z")
|
||||
|
||||
|
||||
def _author_lines(record) -> list[str]: # noqa: ANN001 - ChangeRecord, avoid cycle
|
||||
lines: list[str] = []
|
||||
author = record.author
|
||||
if author is not None:
|
||||
lines.append(f" Last modified by: {author.identifier}")
|
||||
ts = _format_ts(author.timestamp)
|
||||
if ts:
|
||||
lines.append(f" Modified at: {ts}")
|
||||
else:
|
||||
lines.append(" Last modified by: UNKNOWN")
|
||||
return lines
|
||||
|
||||
|
||||
def build_commit_message(
|
||||
change_set: ChangeSet,
|
||||
run: TrackerRun,
|
||||
collected_at: dt.datetime | None = None,
|
||||
app_version: str = "1.0.0",
|
||||
) -> str:
|
||||
"""Build the complete commit message per Sections 15/16/36."""
|
||||
counts = change_set.summary_counts()
|
||||
moment = collected_at or run.started_at
|
||||
subject = f"SCADA automatic change capture - {_format_ts(moment)}"
|
||||
|
||||
lines: list[str] = [subject, ""]
|
||||
|
||||
lines.append("Summary:")
|
||||
for label, key in _SUMMARY_LABELS:
|
||||
lines.append(f" {label} {counts[key]}")
|
||||
lines.append("")
|
||||
|
||||
for heading, records in (
|
||||
("Added:", change_set.added),
|
||||
("Modified:", change_set.modified),
|
||||
("Renamed:", change_set.renamed),
|
||||
("Deleted:", change_set.deleted),
|
||||
):
|
||||
if not records:
|
||||
continue
|
||||
lines.append(heading)
|
||||
for record in records:
|
||||
if record.change_type.value == "RENAMED" and record.previous_path:
|
||||
lines.append(f" {record.previous_path} -> {record.path}")
|
||||
else:
|
||||
lines.append(f" {record.path}")
|
||||
if record.needs_author:
|
||||
lines.extend(_author_lines(record))
|
||||
lines.append("")
|
||||
|
||||
# Structured trailers (Section 36)
|
||||
lines.append(f"Tracker-Version: {app_version}")
|
||||
lines.extend(run.trailer_lines)
|
||||
lines.append(f"Tracker-Changes: {len(change_set.all_records())}")
|
||||
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Configuration loading and validation (Section 26).
|
||||
|
||||
YAML is the primary source; environment variables using the
|
||||
GIT_AUTO_TRACKER_<SECTION>__KEY convention override individual values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from git_auto_tracker.errors import ConfigurationError
|
||||
|
||||
_ENV_PREFIX = "GIT_AUTO_TRACKER_"
|
||||
_SECRET_KEYS = {"password", "token", "secret", "user_email"}
|
||||
|
||||
|
||||
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
||||
for key, value in override.items():
|
||||
if isinstance(value, dict) and isinstance(base.get(key), dict):
|
||||
_deep_merge(base[key], value)
|
||||
else:
|
||||
base[key] = value
|
||||
return base
|
||||
|
||||
|
||||
def _coerce(raw: str) -> Any:
|
||||
"""Coerce environment strings to bool/int/float where natural."""
|
||||
low = raw.lower()
|
||||
if low in ("true", "yes"):
|
||||
return True
|
||||
if low in ("false", "no"):
|
||||
return False
|
||||
for cast in (int, float):
|
||||
try:
|
||||
return cast(raw) # type: ignore[call-overload]
|
||||
except ValueError:
|
||||
continue
|
||||
return raw
|
||||
|
||||
|
||||
def _apply_env_overrides(config: dict[str, Any], environ: dict[str, str]) -> dict[str, Any]:
|
||||
for name, value in environ.items():
|
||||
if not name.startswith(_ENV_PREFIX):
|
||||
continue
|
||||
remainder = name[len(_ENV_PREFIX) :]
|
||||
parts = remainder.split("__")
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
node = config
|
||||
for part in parts[:-1]:
|
||||
key = part.lower()
|
||||
child = node.get(key)
|
||||
if not isinstance(child, dict):
|
||||
child = {}
|
||||
node[key] = child
|
||||
node = child
|
||||
node[parts[-1].lower()] = _coerce(value)
|
||||
return config
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileFilters:
|
||||
include: list[str] = field(default_factory=lambda: ["**/*"])
|
||||
exclude: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthorResolutionConfig:
|
||||
resolvers: list[str] = field(default_factory=lambda: ["sidecar_metadata"])
|
||||
minimum_confidence: str = "probable"
|
||||
fallback: str = "unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SecurityConfig:
|
||||
forbidden_patterns: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoggingConfig:
|
||||
level: str = "INFO"
|
||||
console: bool = True
|
||||
file: str | None = None
|
||||
max_size_mb: int = 20
|
||||
backup_count: int = 10
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppConfig:
|
||||
repository_path: Path
|
||||
branch: str
|
||||
git_user_name: str
|
||||
git_user_email: str
|
||||
filters: FileFilters
|
||||
security: SecurityConfig
|
||||
author_resolution: AuthorResolutionConfig
|
||||
logging: LoggingConfig
|
||||
timezone: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any], config_dir: Path) -> AppConfig:
|
||||
try:
|
||||
repository = data["repository"]
|
||||
git_cfg = data.get("git", {})
|
||||
files_cfg = data.get("files", {})
|
||||
security_cfg = data.get("security", {})
|
||||
author_cfg = data.get("author_resolution", {})
|
||||
logging_cfg = data.get("logging", {})
|
||||
app_cfg = data.get("application", {})
|
||||
|
||||
repo_path_str = repository["path"]
|
||||
if not isinstance(repo_path_str, str) or not repo_path_str:
|
||||
raise ConfigurationError("repository.path must be a non-empty string")
|
||||
repo_path = Path(repo_path_str)
|
||||
if not repo_path.is_absolute():
|
||||
repo_path = (config_dir / repo_path).resolve()
|
||||
|
||||
branch = repository.get("branch") or "main"
|
||||
if not isinstance(branch, str) or not branch:
|
||||
raise ConfigurationError("repository.branch must be a non-empty string")
|
||||
|
||||
filters = FileFilters(
|
||||
include=list(files_cfg.get("include", ["**/*"])),
|
||||
exclude=list(files_cfg.get("exclude", [])),
|
||||
)
|
||||
if not filters.include:
|
||||
raise ConfigurationError("files.include must not be empty")
|
||||
|
||||
return cls(
|
||||
repository_path=repo_path,
|
||||
branch=branch,
|
||||
git_user_name=git_cfg.get("user_name") or "Git Auto Tracker",
|
||||
git_user_email=git_cfg.get("user_email") or "git-auto-tracker@localhost",
|
||||
filters=filters,
|
||||
security=SecurityConfig(
|
||||
forbidden_patterns=list(security_cfg.get("forbidden_patterns", [])),
|
||||
),
|
||||
author_resolution=AuthorResolutionConfig(
|
||||
resolvers=list(author_cfg.get("resolvers", ["sidecar_metadata"])),
|
||||
minimum_confidence=author_cfg.get("minimum_confidence", "probable"),
|
||||
fallback=author_cfg.get("fallback", "unknown"),
|
||||
),
|
||||
logging=LoggingConfig(
|
||||
level=logging_cfg.get("level", "INFO"),
|
||||
console=bool(logging_cfg.get("console", True)),
|
||||
file=logging_cfg.get("file"),
|
||||
max_size_mb=int(logging_cfg.get("max_size_mb", 20)),
|
||||
backup_count=int(logging_cfg.get("backup_count", 10)),
|
||||
),
|
||||
timezone=app_cfg.get("timezone"),
|
||||
)
|
||||
except ConfigurationError:
|
||||
raise
|
||||
except KeyError as exc:
|
||||
raise ConfigurationError(f"missing required configuration key: {exc}") from exc
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ConfigurationError(f"invalid configuration value: {exc}") from exc
|
||||
|
||||
|
||||
def load_config(path: Path, environ: dict[str, str] | None = None) -> AppConfig:
|
||||
"""Load YAML configuration with environment overrides applied."""
|
||||
if not path.is_file():
|
||||
raise ConfigurationError(f"configuration file not found: {path}")
|
||||
try:
|
||||
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except yaml.YAMLError as exc:
|
||||
raise ConfigurationError(f"invalid YAML in {path}: {exc}") from exc
|
||||
if not isinstance(raw, dict):
|
||||
raise ConfigurationError(f"configuration root in {path} must be a mapping")
|
||||
|
||||
merged = _apply_env_overrides(raw, os.environ if environ is None else environ)
|
||||
return AppConfig.from_dict(merged, config_dir=path.parent)
|
||||
|
||||
|
||||
def redact(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return a copy with secret-like values replaced (used by `config`)."""
|
||||
def walk(node: Any) -> Any:
|
||||
if isinstance(node, dict):
|
||||
return {
|
||||
key: ("<redacted>" if any(s in key.lower() for s in _SECRET_KEYS) else walk(value))
|
||||
for key, value in node.items()
|
||||
}
|
||||
if isinstance(node, list):
|
||||
return [walk(item) for item in node]
|
||||
return node
|
||||
|
||||
return walk(data)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Error taxonomy and process exit codes (Sections 24 and 25)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
|
||||
|
||||
class ExitCode(enum.IntEnum):
|
||||
"""Process exit codes (Section 25)."""
|
||||
|
||||
SUCCESS = 0
|
||||
GENERAL_FAILURE = 1
|
||||
CONFIGURATION_ERROR = 2
|
||||
REPOSITORY_ERROR = 3
|
||||
GIT_ERROR = 4
|
||||
REPOSITORY_LOCKED = 5
|
||||
COLLECTION_FAILED = 6
|
||||
SECURITY_VIOLATION = 7
|
||||
|
||||
|
||||
class TrackerError(Exception):
|
||||
"""Base class for tracker errors carrying an exit code."""
|
||||
|
||||
exit_code: ExitCode = ExitCode.GENERAL_FAILURE
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
|
||||
|
||||
class ConfigurationError(TrackerError):
|
||||
exit_code = ExitCode.CONFIGURATION_ERROR
|
||||
|
||||
|
||||
class RepositoryError(TrackerError):
|
||||
exit_code = ExitCode.REPOSITORY_ERROR
|
||||
|
||||
|
||||
class GitError(TrackerError):
|
||||
exit_code = ExitCode.GIT_ERROR
|
||||
|
||||
|
||||
class LockError(TrackerError):
|
||||
exit_code = ExitCode.REPOSITORY_LOCKED
|
||||
|
||||
|
||||
class SecurityError(TrackerError):
|
||||
exit_code = ExitCode.SECURITY_VIOLATION
|
||||
|
||||
|
||||
class CollectionError(TrackerError):
|
||||
exit_code = ExitCode.COLLECTION_FAILED
|
||||
|
||||
|
||||
class InternalError(TrackerError):
|
||||
exit_code = ExitCode.GENERAL_FAILURE
|
||||
@@ -0,0 +1 @@
|
||||
"""Git integration (Section 28)."""
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Git CLI integration (Section 28).
|
||||
|
||||
All Git output containing paths uses NUL-terminated (-z) formats and is
|
||||
decoded as UTF-8 (Section 6.2). Argument arrays are always used; no shell
|
||||
strings are constructed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from git_auto_tracker.errors import GitError
|
||||
|
||||
_MIN_GIT_VERSION = (2, 20)
|
||||
|
||||
|
||||
def _run_git(
|
||||
args: Sequence[str],
|
||||
cwd: Path,
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
"""Run git with argument array; returns completed process with UTF-8 text."""
|
||||
try:
|
||||
return subprocess.run( # noqa: SIM115 - short-lived, no handles kept
|
||||
["git", *args],
|
||||
cwd=str(cwd),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=False,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise GitError("git executable not found on PATH") from exc
|
||||
except OSError as exc:
|
||||
raise GitError(f"failed to execute git: {exc}") from exc
|
||||
|
||||
|
||||
def check_git_available() -> str:
|
||||
"""Verify `git --version`; returns the version string (Section 28)."""
|
||||
proc = subprocess.run( # noqa: SIM115
|
||||
["git", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise GitError("git is not installed or not on PATH")
|
||||
return (proc.stdout or "").strip()
|
||||
|
||||
|
||||
def _parse_version(version_line: str) -> tuple[int, ...]:
|
||||
digits = "".join(ch if ch.isdigit() else "." for ch in version_line)
|
||||
return tuple(int(part) for part in digits.split(".") if part.isdigit())
|
||||
|
||||
|
||||
def verify_git_version(version_line: str) -> bool:
|
||||
"""True if the git version meets the minimum (>= 2.20)."""
|
||||
parts = _parse_version(version_line)
|
||||
return parts[:2] >= _MIN_GIT_VERSION
|
||||
|
||||
|
||||
class GitCommand:
|
||||
"""Thin wrapper around the git CLI for one repository."""
|
||||
|
||||
def __init__(self, workdir: Path) -> None:
|
||||
self.workdir = workdir
|
||||
|
||||
def run(
|
||||
self,
|
||||
args: Sequence[str],
|
||||
check: bool = True,
|
||||
allow_stderr: bool = False,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
proc = _run_git(args, self.workdir, check=False)
|
||||
if check and proc.returncode != 0:
|
||||
stderr = (proc.stderr or "").strip()
|
||||
raise GitError(f"git {' '.join(args)} failed: {stderr or proc.returncode}")
|
||||
return proc
|
||||
|
||||
def run_bytes(self, args: Sequence[str]) -> bytes:
|
||||
"""Run git capturing raw bytes (needed for -z output)."""
|
||||
try:
|
||||
proc = subprocess.run( # noqa: SIM115
|
||||
["git", *args],
|
||||
cwd=str(self.workdir),
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise GitError("git executable not found on PATH") from exc
|
||||
except OSError as exc:
|
||||
raise GitError(f"failed to execute git: {exc}") from exc
|
||||
if proc.returncode != 0:
|
||||
stderr = (proc.stderr or b"").decode("utf-8", errors="replace").strip()
|
||||
raise GitError(f"git {' '.join(args)} failed: {stderr or proc.returncode}")
|
||||
return proc.stdout
|
||||
|
||||
def rev_parse(self, name: str) -> str:
|
||||
proc = self.run(["rev-parse", name])
|
||||
return (proc.stdout or "").strip()
|
||||
|
||||
def rev_parse_git_dir(self) -> Path:
|
||||
out = self.run(["rev-parse", "--absolute-git-dir"]).stdout.strip()
|
||||
return Path(out)
|
||||
|
||||
def symbolic_ref(self) -> str | None:
|
||||
"""Current branch name, or None when detached HEAD."""
|
||||
proc = self.run(["symbolic-ref", "--short", "HEAD"], check=False)
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
return (proc.stdout or "").strip()
|
||||
|
||||
def commit(self, message: str, author_name: str, author_email: str) -> str:
|
||||
"""Create a commit from the index; returns the new commit hash."""
|
||||
author = f"{author_name} <{author_email}>"
|
||||
self.run(
|
||||
["commit", "-m", message, f"--author={author}"],
|
||||
)
|
||||
return self.rev_parse("HEAD")
|
||||
|
||||
def commit_file_status(self) -> list[tuple[str, str, str | None]]:
|
||||
"""Staged change set as (status, path, previous_path) tuples.
|
||||
|
||||
Uses `git diff --cached --name-status -z --no-renames` (Section 14,
|
||||
step 5). Rename detection is disabled so the staged set is compared
|
||||
against the expected add/delete fallback representation mandated by
|
||||
Section 6.1 (renames reported as one deletion plus one addition).
|
||||
"""
|
||||
raw = self.run_bytes(
|
||||
["diff", "--cached", "--name-status", "-z", "--no-renames"]
|
||||
)
|
||||
return _parse_name_status_z(raw, expect_renames=False)
|
||||
|
||||
|
||||
def _parse_name_status_z(
|
||||
raw: bytes,
|
||||
expect_renames: bool,
|
||||
) -> list[tuple[str, str, str | None]]:
|
||||
"""Parse NUL-terminated `--name-status -z` output.
|
||||
|
||||
For rename/copy entries (status starting with R or C followed by a
|
||||
score), the record holds `status\\0new\\0old\\0`; all other entries are
|
||||
`status\\0path\\0`.
|
||||
"""
|
||||
records: list[tuple[str, str, str | None]] = []
|
||||
fields = raw.split(b"\x00")
|
||||
i = 0
|
||||
while i < len(fields):
|
||||
status_bytes = fields[i]
|
||||
if not status_bytes:
|
||||
i += 1
|
||||
continue
|
||||
status = status_bytes.decode("utf-8")
|
||||
i += 1
|
||||
if i >= len(fields):
|
||||
break
|
||||
path = fields[i].decode("utf-8")
|
||||
i += 1
|
||||
previous: str | None = None
|
||||
if status[0] in "RC" and i < len(fields):
|
||||
previous = fields[i].decode("utf-8")
|
||||
i += 1
|
||||
records.append((status, path, previous))
|
||||
return records
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Change detection based on `git status --porcelain -z` (Section 6.2)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from git_auto_tracker.changes.models import ChangeType
|
||||
from git_auto_tracker.errors import GitError
|
||||
from git_auto_tracker.git.repository import GitCommand
|
||||
|
||||
# XY codes (https://git-scm.com/docs/git-status):
|
||||
# X = index state, Y = worktree state.
|
||||
# The tracker commits what `git add` would stage (the X column), but it must
|
||||
# also surface worktree-only states that plain `git add` would capture.
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RawStatusEntry:
|
||||
"""One entry parsed from `git status --porcelain -z`."""
|
||||
|
||||
x: str
|
||||
y: str
|
||||
path: str
|
||||
previous_path: str | None
|
||||
|
||||
@property
|
||||
def change_type(self) -> ChangeType:
|
||||
"""Map the XY code to the tracker's ChangeType (Section 6.1).
|
||||
|
||||
Priority follows what `git add -A` would stage for the path:
|
||||
untracked files are additions, deletions win over index states,
|
||||
and renames are detected from the pair entry.
|
||||
"""
|
||||
if self.x == "?" and self.y == "?":
|
||||
return ChangeType.ADDED
|
||||
if "R" in (self.x, self.y):
|
||||
return ChangeType.RENAMED
|
||||
if "D" in (self.x, self.y):
|
||||
return ChangeType.DELETED
|
||||
if "A" in (self.x, self.y):
|
||||
return ChangeType.ADDED
|
||||
return ChangeType.MODIFIED
|
||||
|
||||
@classmethod
|
||||
def from_zy_pair(
|
||||
cls,
|
||||
xy: str,
|
||||
path: str,
|
||||
previous_path: str | None,
|
||||
) -> RawStatusEntry:
|
||||
if len(xy) != 2:
|
||||
raise GitError(f"unexpected XY code {xy!r} in git status output")
|
||||
return cls(x=xy[0], y=xy[1], path=path, previous_path=previous_path)
|
||||
|
||||
|
||||
def parse_porcelain_z(raw: bytes) -> list[RawStatusEntry]:
|
||||
"""Parse `git status --porcelain -z` output bytes.
|
||||
|
||||
Format: `XY path\\0` and, for renames/copies, `XY new\\0old\\0`
|
||||
(current path first, then the previous path). Trailing NULs
|
||||
terminate the stream.
|
||||
"""
|
||||
# Valid XY column values: space = unmodified, ? = untracked,
|
||||
# A/M/D/T = added/modified/deleted/typechanged, R/C = renamed/copied,
|
||||
# U = unmerged, ! = ignored.
|
||||
valid = " ARCDUTM?!"
|
||||
entries: list[RawStatusEntry] = []
|
||||
fields = raw.split(b"\x00")
|
||||
i = 0
|
||||
while i < len(fields):
|
||||
chunk = fields[i]
|
||||
if len(chunk) < 4:
|
||||
# Empty trailing chunk, stray separator, or malformed short entry
|
||||
i += 1
|
||||
continue
|
||||
entry = chunk.decode("utf-8")
|
||||
xy = entry[:2]
|
||||
if xy[0] not in valid or xy[1] not in valid:
|
||||
raise GitError(f"unexpected XY code {xy!r} in git status output")
|
||||
path = entry[3:]
|
||||
previous: str | None = None
|
||||
# Rename/copy entries are followed by the original path
|
||||
if xy[0] in "RC" and i + 1 < len(fields):
|
||||
i += 1
|
||||
previous = fields[i].decode("utf-8")
|
||||
entries.append(RawStatusEntry.from_zy_pair(xy, path, previous))
|
||||
i += 1
|
||||
return entries
|
||||
|
||||
|
||||
class ChangeDetector:
|
||||
"""Detects working-tree changes relative to HEAD (Section 6.1)."""
|
||||
|
||||
def __init__(self, git: GitCommand) -> None:
|
||||
self.git = git
|
||||
|
||||
def detect(self) -> list[RawStatusEntry]:
|
||||
# --untracked-files=all: porcelain collapses untracked DIRECTORIES to
|
||||
# a single entry; the tracker needs per-file records so that staging
|
||||
# and staged-set verification operate on identical path sets.
|
||||
raw = self.git.run_bytes(
|
||||
["status", "--porcelain", "-z", "--untracked-files=all"]
|
||||
)
|
||||
return parse_porcelain_z(raw)
|
||||
|
||||
|
||||
def snapshot_file_attributes(record_path: str, repository_root: Path) -> dict[str, object]:
|
||||
"""Capture size/mtime of a file for concurrent-modification checks (Section 20)."""
|
||||
absolute = repository_root / Path(record_path)
|
||||
try:
|
||||
stat = absolute.stat()
|
||||
except OSError:
|
||||
return {"exists": False}
|
||||
return {"exists": True, "size": stat.st_size, "mtime_ns": stat.st_mtime_ns}
|
||||
|
||||
|
||||
def attributes_changed(before: dict[str, object], after: dict[str, object]) -> bool:
|
||||
"""True if the file was modified between the two snapshots."""
|
||||
return before != after
|
||||
@@ -0,0 +1 @@
|
||||
"""Repository locking (Section 21)."""
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Repository-level execution lock (Section 21).
|
||||
|
||||
The lock file lives OUTSIDE the tracked repository (as a sibling of the
|
||||
repository directory), so it never shows up in `git status` (FR-028).
|
||||
Stale locks are detected via live-process check (same host) or an age
|
||||
threshold (cross-host); removal is logged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from git_auto_tracker.errors import LockError
|
||||
|
||||
|
||||
def default_lock_path(repository_path: Path) -> Path:
|
||||
"""Sibling lock file: <repository-path>.git-auto-tracker.lock (Section 21)."""
|
||||
return repository_path.with_name(repository_path.name + ".git-auto-tracker.lock")
|
||||
|
||||
|
||||
def _process_alive(pid: int) -> bool:
|
||||
try:
|
||||
if os.name == "nt":
|
||||
# Windows: Process existence check without psutil.
|
||||
import ctypes
|
||||
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
STILL_ACTIVE = 259
|
||||
handle = ctypes.windll.kernel32.OpenProcess( # type: ignore[union-attr]
|
||||
PROCESS_QUERY_LIMITED_INFORMATION, False, pid
|
||||
)
|
||||
if not handle:
|
||||
return False
|
||||
try:
|
||||
exit_code = ctypes.c_ulong()
|
||||
if ctypes.windll.kernel32.GetExitCodeProcess( # type: ignore[union-attr]
|
||||
handle, ctypes.byref(exit_code)
|
||||
):
|
||||
return exit_code.value == STILL_ACTIVE
|
||||
return False
|
||||
finally:
|
||||
ctypes.windll.kernel32.CloseHandle(handle) # type: ignore[union-attr]
|
||||
# POSIX
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except (OSError, AttributeError, ImportError):
|
||||
return False
|
||||
|
||||
|
||||
class RepositoryLock:
|
||||
"""Advisory repository lock guarded by create-exclusive semantics."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
lock_path: Path,
|
||||
stale_age_seconds: int = 6 * 3600,
|
||||
clock: type[dt.datetime] = dt.datetime,
|
||||
) -> None:
|
||||
self.lock_path = lock_path
|
||||
self.stale_age_seconds = stale_age_seconds
|
||||
self._clock = clock
|
||||
self._acquired = False
|
||||
|
||||
def acquire(self, pid: int | None = None, hostname: str | None = None) -> None:
|
||||
"""Acquire the lock; raises LockError if held by a live execution."""
|
||||
if self._acquired:
|
||||
return
|
||||
info = {
|
||||
"process_id": pid if pid is not None else os.getpid(),
|
||||
"hostname": hostname or socket.gethostname(),
|
||||
"start_time": self._clock.now(dt.UTC).isoformat(),
|
||||
}
|
||||
parent = self.lock_path.parent
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
fd = os.open(self.lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
||||
except FileExistsError:
|
||||
self._handle_existing_lock()
|
||||
# Retry once after stale-lock recovery
|
||||
try:
|
||||
fd = os.open(self.lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
||||
except FileExistsError:
|
||||
raise LockError(
|
||||
f"repository locked by another execution: {self.lock_path}"
|
||||
) from None
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(info, handle)
|
||||
self._acquired = True
|
||||
|
||||
def _handle_existing_lock(self) -> None:
|
||||
"""Inspect an existing lock; remove it when stale."""
|
||||
try:
|
||||
raw = self.lock_path.read_text(encoding="utf-8")
|
||||
info = json.loads(raw)
|
||||
except (OSError, ValueError):
|
||||
# Corrupt lock: treat as stale (best effort recovery)
|
||||
self._remove_quietly()
|
||||
return
|
||||
pid = info.get("process_id")
|
||||
hostname = info.get("hostname")
|
||||
start_time = info.get("start_time")
|
||||
if (
|
||||
isinstance(pid, int)
|
||||
and hostname == socket.gethostname()
|
||||
and _process_alive(pid)
|
||||
):
|
||||
raise LockError(f"repository locked by live process {pid} on {hostname}")
|
||||
if isinstance(start_time, str):
|
||||
try:
|
||||
started = dt.datetime.fromisoformat(start_time)
|
||||
age = (self._clock.now(dt.UTC) - started).total_seconds()
|
||||
if age < self.stale_age_seconds:
|
||||
# Young lock from a dead/foreign process: refuse to steal it
|
||||
raise LockError(
|
||||
f"repository locked (young lock, age {int(age)}s < "
|
||||
f"{self.stale_age_seconds}s): {self.lock_path}"
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
# Lock is old or its process is gone: stale, remove it
|
||||
self._remove_quietly()
|
||||
|
||||
def _remove_quietly(self) -> None:
|
||||
with contextlib.suppress(OSError):
|
||||
self.lock_path.unlink(missing_ok=True)
|
||||
|
||||
def release(self) -> None:
|
||||
"""Release the lock; safe to call multiple times."""
|
||||
if not self._acquired:
|
||||
return
|
||||
self._remove_quietly()
|
||||
self._acquired = False
|
||||
|
||||
def __enter__(self) -> RepositoryLock:
|
||||
self.acquire()
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: object) -> None:
|
||||
self.release()
|
||||
|
||||
|
||||
def wait_for_lock_release(lock_path: Path, timeout_seconds: float = 5.0) -> bool:
|
||||
"""Sleep-poll helper for tests; returns True if the lock disappeared."""
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
while time.monotonic() < deadline:
|
||||
if not lock_path.exists():
|
||||
return True
|
||||
time.sleep(0.05)
|
||||
return not lock_path.exists()
|
||||
@@ -0,0 +1 @@
|
||||
"""Logging and audit (Sections 22 and 23)."""
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Logging setup with rotation (Sections 22 and 23).
|
||||
|
||||
Relative log paths resolve against the configuration file location.
|
||||
The log file must not live inside the tracked repository (Section 23).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import time
|
||||
import logging
|
||||
import logging.handlers
|
||||
import zoneinfo
|
||||
from pathlib import Path
|
||||
|
||||
from git_auto_tracker.config import LoggingConfig
|
||||
|
||||
_FORMAT = "%(asctime)s %(levelname)s %(message)s"
|
||||
_DATE_FORMAT = "%Y-%m-%d %H:%M:%S%z"
|
||||
_LOGGER_NAME = "git_auto_tracker"
|
||||
|
||||
|
||||
def _resolve_zone(name: str | None) -> dt.tzinfo | None:
|
||||
"""Return the tzinfo for a configured timezone name, None for system time."""
|
||||
if not name:
|
||||
return None
|
||||
try:
|
||||
return zoneinfo.ZoneInfo(name)
|
||||
except zoneinfo.ZoneInfoNotFoundError:
|
||||
raise ValueError(f"unknown timezone: {name}") from None
|
||||
|
||||
|
||||
def setup_logging(
|
||||
config: LoggingConfig,
|
||||
config_dir: Path,
|
||||
repository_path: Path | None = None,
|
||||
override_level: str | None = None,
|
||||
) -> logging.Logger:
|
||||
"""Configure and return the application logger.
|
||||
|
||||
A StreamHandler (console) and/or RotatingFileHandler are attached per
|
||||
configuration. Relative file paths resolve against the configuration
|
||||
file location (Section 23).
|
||||
"""
|
||||
logger = logging.getLogger(_LOGGER_NAME)
|
||||
logger.setLevel(getattr(logging, (override_level or config.level).upper(), logging.INFO))
|
||||
logger.propagate = False
|
||||
|
||||
# Reset handlers so repeated setup (tests) does not duplicate output
|
||||
for handler in list(logger.handlers):
|
||||
logger.removeHandler(handler)
|
||||
handler.close()
|
||||
|
||||
formatter = logging.Formatter(_FORMAT, datefmt=_DATE_FORMAT)
|
||||
|
||||
if config.console:
|
||||
console = logging.StreamHandler()
|
||||
console.setFormatter(formatter)
|
||||
logger.addHandler(console)
|
||||
|
||||
if config.file:
|
||||
log_path = Path(config.file)
|
||||
if not log_path.is_absolute():
|
||||
log_path = (config_dir / log_path).resolve()
|
||||
if repository_path is not None:
|
||||
try:
|
||||
log_path.relative_to(repository_path.resolve())
|
||||
except ValueError:
|
||||
pass # outside the repository: fine
|
||||
else:
|
||||
logging.getLogger(_LOGGER_NAME).warning(
|
||||
"log file is inside the tracked repository: %s (Section 23)", log_path
|
||||
)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
rotating = logging.handlers.RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=config.max_size_mb * 1024 * 1024,
|
||||
backupCount=config.backup_count,
|
||||
encoding="utf-8",
|
||||
)
|
||||
rotating.setFormatter(formatter)
|
||||
logger.addHandler(rotating)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
def get_logger() -> logging.Logger:
|
||||
return logging.getLogger(_LOGGER_NAME)
|
||||
@@ -0,0 +1 @@
|
||||
"""Security validation (Section 33)."""
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Security validation entry point (Section 33).
|
||||
|
||||
Delegates to changes.filters for pattern matching; kept as a separate
|
||||
component so future policies (skip, quarantine) can evolve independently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from git_auto_tracker.changes.filters import find_forbidden, validate_security
|
||||
from git_auto_tracker.changes.models import ChangeSet
|
||||
from git_auto_tracker.config import SecurityConfig
|
||||
|
||||
__all__ = ["SecurityValidator", "find_forbidden", "validate_security"]
|
||||
|
||||
|
||||
class SecurityValidator:
|
||||
"""Checks a ChangeSet against configured forbidden patterns."""
|
||||
|
||||
def __init__(self, security: SecurityConfig) -> None:
|
||||
self._security = security
|
||||
|
||||
def find_forbidden(self, change_set: ChangeSet) -> list[str]:
|
||||
return find_forbidden(
|
||||
[r.path for r in change_set.all_records()] + [
|
||||
r.previous_path or r.path for r in change_set.renamed
|
||||
],
|
||||
self._security,
|
||||
)
|
||||
|
||||
def validate(self, change_set: ChangeSet) -> None:
|
||||
"""Raise SecurityError when a forbidden file is present."""
|
||||
validate_security(change_set, self._security)
|
||||
Reference in New Issue
Block a user