🚧 Add export feature and refined exclusion info

This commit is contained in:
Jonas Bengtsson
2026-09-13 12:04:42 +02:00
parent 1d67330f27
commit 3bbfd6c161
10 changed files with 637 additions and 16 deletions
+82
View File
@@ -295,6 +295,88 @@ class TestExtraction:
assert before == after
class TestExportHistory:
"""export subcommand: collected history becomes analysis-ready rows."""
def _collect_two_cycles(self, git_repo: Path, config: dict, tmp_path: Path) -> None:
# Cycle 1: one addition with sidecar attribution
(git_repo / "config.xml").write_text("<c/>", encoding="utf-8")
(git_repo / "config.xml.metadata").write_text(
"last_modified_by=DOMAIN\\jsmith\n", encoding="utf-8"
)
_run(config, tmp_path)
# Cycle 2: one deletion (bare D line in the history)
(git_repo / "config.xml").unlink()
_run(config, tmp_path)
def test_export_csv_round_trip(
self, git_repo: Path, tracker_config: dict, tmp_path: Path
) -> None:
import csv
from git_auto_tracker.config import AppConfig
from git_auto_tracker.exporting.history import export_history
self._collect_two_cycles(git_repo, tracker_config, tmp_path)
app_config = AppConfig.from_dict(tracker_config, config_dir=tmp_path)
output = tmp_path / "history.csv"
count = export_history(app_config, output)
# 3 rows: A config.xml + A config.xml.metadata, then D config.xml
assert count == 3
with output.open(encoding="utf-8", newline="") as handle:
rows = list(csv.DictReader(handle))
assert rows[0]["letter"] == "D" # newest collection first
assert rows[0]["path"] == "config.xml"
assert rows[1]["letter"] == "A"
assert rows[1]["path"] == "config.xml"
assert rows[1]["author"] == "DOMAIN\\jsmith"
assert rows[1]["run_id"] # trailer parsed
def test_export_jsonl_and_since_filter(
self, git_repo: Path, tracker_config: dict, tmp_path: Path
) -> None:
import json
from git_auto_tracker.config import AppConfig
from git_auto_tracker.exporting.history import export_history
self._collect_two_cycles(git_repo, tracker_config, tmp_path)
app_config = AppConfig.from_dict(tracker_config, config_dir=tmp_path)
output = tmp_path / "history.jsonl"
# Future-dated --since excludes everything (2099, not 2999: Git's
# approxidate parser mishandles far-future years and returns all)
assert export_history(app_config, output, since="2099-01-01") == 0
assert output.read_text(encoding="utf-8") == ""
# No filter: both collections' rows present, newest first
assert export_history(app_config, output) == 3
lines = output.read_text(encoding="utf-8").splitlines()
parsed = [json.loads(line) for line in lines]
assert parsed[0]["letter"] == "D"
assert parsed[1]["letter"] == "A"
assert parsed[0]["collected_at"] >= parsed[-1]["collected_at"]
def test_cli_export_command(
self, git_repo: Path, tracker_config: dict, tmp_path: Path, capsys
) -> None:
from git_auto_tracker.cli import main
self._collect_two_cycles(git_repo, tracker_config, tmp_path)
import yaml
config_path = tmp_path / "cfg.yaml"
config_path.write_text(yaml.safe_dump(tracker_config), encoding="utf-8")
output = tmp_path / "out.csv"
exit_code = main(["--config", str(config_path), "export", "-o", str(output)])
assert exit_code == 0
assert output.is_file()
captured = capsys.readouterr().out
assert "3 file-change record(s)" in captured
class TestSafetyAborts:
def test_pre_existing_staged_changes_abort(
self, git_repo: Path, tracker_config: dict, tmp_path: Path
+124
View File
@@ -0,0 +1,124 @@
"""Tests for exclusion reporting and log-level policy (Sections 7 and 22).
Excluded paths and the rule that matched them are logged at DEBUG level;
the INFO summary carries only the in-scope/excluded split. Commit messages
never contain exclusion details (Section 7).
"""
from __future__ import annotations
import logging
from git_auto_tracker.changes.filters import (
Exclusion,
exclusion_reason,
filter_change_set,
inclusion_reason,
)
from git_auto_tracker.changes.models import ChangeRecord, ChangeSet, ChangeType
from git_auto_tracker.config import FileFilters
class TestReasonFunctions:
def test_exclusion_reason_names_the_pattern(self) -> None:
filters = FileFilters(include=["**/*"], exclude=["runtime/**", "**/*.log"])
assert exclusion_reason("runtime/cache.dat", filters) == "matched exclude 'runtime/**'"
assert exclusion_reason("logs/a.log", filters) == "matched exclude '**/*.log'"
assert exclusion_reason("keep.xml", filters) is None
def test_inclusion_reason_when_out_of_scope(self) -> None:
filters = FileFilters(include=["Stations/**"], exclude=[])
assert inclusion_reason("System/global.xml", filters) == "no include pattern matched"
assert inclusion_reason("Stations/PS1.xml", filters) is None
def test_empty_include_means_everything_included(self) -> None:
filters = FileFilters(include=[], exclude=["**/*.tmp"])
assert inclusion_reason("anything.xml", filters) is None
def test_legacy_predicates_still_work(self) -> None:
filters = FileFilters(include=["**/*"], exclude=["**/*.log"])
from git_auto_tracker.changes.filters import is_excluded, is_included
assert is_included("a.xml", filters) is True
assert is_excluded("a.log", filters) is True
assert is_excluded("a.xml", filters) is False
class TestFilterChangeSet:
def _change_set(self) -> ChangeSet:
return ChangeSet(
added=[
ChangeRecord(path="runtime/cache.dat", change_type=ChangeType.ADDED),
ChangeRecord(path="Stations/PS1.xml", change_type=ChangeType.ADDED),
],
modified=[
ChangeRecord(path="logs/app.log", change_type=ChangeType.MODIFIED),
ChangeRecord(path="System/global.xml", change_type=ChangeType.MODIFIED),
],
)
def test_exclusions_carry_reasons(self) -> None:
filters = FileFilters(include=["**/*"], exclude=["runtime/**", "**/*.log"])
kept, excluded = filter_change_set(self._change_set(), filters)
assert [r.path for r in kept.added] == ["Stations/PS1.xml"]
assert [r.path for r in kept.modified] == ["System/global.xml"]
by_path = {e.path: e.reason for e in excluded}
assert by_path["runtime/cache.dat"] == "matched exclude 'runtime/**'"
assert by_path["logs/app.log"] == "matched exclude '**/*.log'"
def test_out_of_scope_excluded_with_include_reason(self) -> None:
filters = FileFilters(include=["Stations/**"], exclude=["**/*.log"])
_, excluded = filter_change_set(self._change_set(), filters)
by_path = {e.path: e.reason for e in excluded}
assert by_path["System/global.xml"] == "no include pattern matched"
assert by_path["runtime/cache.dat"] == "no include pattern matched"
assert by_path["logs/app.log"] == "matched exclude '**/*.log'"
def test_exclusion_is_dataclass(self) -> None:
record = Exclusion(path="x.tmp", reason="matched exclude '**/*.tmp'")
assert record.path == "x.tmp"
assert "x.tmp" in record.reason or "**/*.tmp" in record.reason
class TestLogLevels:
"""INFO summary only; per-file detail at DEBUG (application wiring)."""
def test_collect_logs_split_and_debug_details(
self, git_repo, tracker_config, tmp_path, caplog
) -> None:
from git_auto_tracker.application import ApplicationController
from git_auto_tracker.config import AppConfig
from git_auto_tracker.logging.setup import setup_logging
(git_repo / "junk.log").write_text("noise", encoding="utf-8")
(git_repo / "real.xml").write_text("<r/>", encoding="utf-8")
config = AppConfig.from_dict(tracker_config, config_dir=tmp_path)
setup_logging(
config.logging,
config_dir=tmp_path,
repository_path=config.repository_path,
override_level="DEBUG",
)
# The app logger does not propagate (setup_logging), so caplog's
# root-attached handler never sees the records; attach it directly.
app_logger = logging.getLogger("git_auto_tracker")
app_logger.addHandler(caplog.handler)
try:
controller = ApplicationController(config)
outcome = controller.collect()
finally:
app_logger.removeHandler(caplog.handler)
assert outcome.change_count == 1 # real.xml only
debug_text = caplog.text
# INFO summary: the split, no per-file exclusion detail
assert "Detected 2 change(s): 1 in scope, 1 excluded by filters" in debug_text
# DEBUG detail: path plus the matching rule
assert "Excluded: junk.log (matched exclude '**/*.log')" in debug_text
# Author resolution details land at DEBUG too
assert "Author resolution detail: real.xml -> source=" in debug_text
# The exclusion must NOT appear in the commit message (Section 7)
assert "junk.log" not in (outcome.commit_message or "")
+118
View File
@@ -0,0 +1,118 @@
"""Unit tests for history export parsing (exporting.history).
The commit-message grammar is defined by message_builder (Section 15);
these tests pin the parser against it.
"""
from __future__ import annotations
from git_auto_tracker.exporting.history import (
HistoryRecord,
parse_message,
to_rows,
)
_MESSAGE = """SCADA automatic change capture - 2026-09-12 08:00:03+02:00
Summary:
Added: 1
Modified: 1
Deleted: 1
Renamed: 0
Changes:
A ; Stations/PS101/pump2.xml ; DOMAIN\\jsmith ; 2026-09-12 07:41:22+02:00
M ; System/global.xml ; UNKNOWN ; 2026-09-12 07:45:01+02:00
D ; Stations/PS027/old-config.xml
Tracker-Version: 1.0.0
Tracker-Run: 01M2ADPMSHSA2TZKVH6PSVDVA0
Tracker-Host: SCADA-SRV-01
Tracker-Changes: 3
"""
class TestParseMessage:
def test_full_message(self) -> None:
record = parse_message(_MESSAGE, ";")
assert record is not None
assert record.collected_at == "2026-09-12 08:00:03+02:00"
assert record.run_id == "01M2ADPMSHSA2TZKVH6PSVDVA0"
assert record.host == "SCADA-SRV-01"
assert len(record.rows) == 3
added = record.rows[0]
assert added["letter"] == "A"
assert added["path"] == "Stations/PS101/pump2.xml"
assert added["author"] == "DOMAIN\\jsmith"
assert added["mtime"] == "2026-09-12 07:41:22+02:00"
modified = record.rows[1]
assert modified["letter"] == "M"
assert modified["author"] == "UNKNOWN"
deleted = record.rows[2]
assert deleted["letter"] == "D"
assert deleted["path"] == "Stations/PS027/old-config.xml"
assert deleted["author"] == "" # bare line (FR-013)
def test_paths_with_semicolon_inside_are_not_broken(self) -> None:
# A path containing ';' is ambiguous by design; the parser splits on
# ' ; ' so a lone ';' inside a filename keeps the line intact here.
message = _MESSAGE.replace(
"Stations/PS101/pump2.xml", " Stations/PS101;backup.xml".strip()
)
record = parse_message(message, ";")
assert record is not None
assert record.rows[0]["path"] == "Stations/PS101;backup.xml"
def test_non_tracker_message_returns_none(self) -> None:
assert parse_message("initial import\n", ";") is None
assert parse_message("", ";") is None
def test_custom_separator(self) -> None:
message = _MESSAGE.replace(" ; ", " | ")
record = parse_message(message, "|")
assert record is not None
assert len(record.rows) == 3
assert record.rows[0]["path"] == "Stations/PS101/pump2.xml"
def test_wrong_separator_yields_no_rows_but_is_tracker_commit(self) -> None:
# Written with ';', parsed with '|': recognized but not splittable.
record = parse_message(_MESSAGE, "|")
assert record is not None
assert record.collected_at # tracker commit detected
assert record.rows == [] # but no reliable rows
def test_rename_line(self) -> None:
message = _MESSAGE.replace(
" D ; Stations/PS027/old-config.xml",
" R ; Stations/PS027/old.xml -> Stations/PS027/new.xml",
)
record = parse_message(message, ";")
assert record is not None
row = record.rows[2]
assert row["letter"] == "R"
assert row["previous_path"] == "Stations/PS027/old.xml"
assert row["path"] == "Stations/PS027/new.xml"
class TestToRows:
def test_long_format(self) -> None:
record = parse_message(_MESSAGE, ";")
assert record is not None
record.commit = "a1b2c3d"
rows = to_rows([record])
assert len(rows) == 3
first = rows[0]
assert first["commit"] == "a1b2c3d"
assert first["run_id"] == "01M2ADPMSHSA2TZKVH6PSVDVA0"
assert first["letter"] == "A"
assert set(first) == {
"commit", "collected_at", "run_id", "host",
"letter", "path", "previous_path", "author", "mtime",
}
def test_empty(self) -> None:
assert to_rows([]) == []
assert to_rows([HistoryRecord(commit="x")]) == []