This commit is contained in:
Jonas Bengtsson
2026-09-12 09:01:14 +02:00
parent e8d6e70f42
commit 17b8525de1
38 changed files with 3247 additions and 57 deletions
+3
View File
@@ -0,0 +1,3 @@
"""git-auto-tracker: unattended Git change tracker for SCADA projects."""
__version__ = "1.0.0"
+8
View File
@@ -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())
+205
View File
@@ -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)"
)
+45
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Author resolution framework (Sections 9-11)."""
+45
View File
@@ -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.
"""
+74
View File
@@ -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,
)
+104
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Change detection and ChangeSet models (Sections 6 and 12)."""
+150
View File
@@ -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
+131
View File
@@ -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()
+135
View File
@@ -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())
+1
View File
@@ -0,0 +1 @@
"""Commit message generation and staging/commit management (Sections 14-16, 36)."""
+87
View File
@@ -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"
+192
View File
@@ -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)
+56
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Git integration (Section 28)."""
+167
View File
@@ -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
+120
View File
@@ -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
+1
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
"""Logging and audit (Sections 22 and 23)."""
+88
View File
@@ -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)