"""Integration tests: full collection cycles against real git repositories. Covers the Section 40 processing sequence and the safety/edge-case list from Section 41. """ from __future__ import annotations import contextlib import subprocess from pathlib import Path import pytest from git_auto_tracker.application import ApplicationController from git_auto_tracker.config import AppConfig from git_auto_tracker.errors import ( ExitCode, RepositoryError, SecurityError, TrackerError, ) from git_auto_tracker.logging.setup import setup_logging def _make_controller(config_dict: dict, tmp_path: Path) -> tuple[AppConfig, ApplicationController]: config = AppConfig.from_dict(config_dict, config_dir=tmp_path) setup_logging(config.logging, config_dir=tmp_path, repository_path=config.repository_path) return config, ApplicationController(config) def _git_out(*args: str, cwd: Path) -> str: """Run git; NUL-terminated output (-z) is returned as decoded text.""" proc = subprocess.run( ["git", *args], cwd=str(cwd), capture_output=True, check=True, ) return proc.stdout.decode("utf-8") def _commit_log(repo: Path) -> list[str]: out = _git_out("log", "--format=%s", cwd=repo) return out.splitlines() def _run(config_dict: dict, tmp_path: Path, dry_run: bool = False): _, controller = _make_controller(config_dict, tmp_path) return controller.collect(dry_run=dry_run) class TestRunCycle: def test_clean_repository_no_commit( self, tracker_config: dict, tmp_path: Path ) -> None: _, controller = _make_controller(tracker_config, tmp_path) outcome = controller.collect() assert outcome.commit_hash is None assert outcome.change_count == 0 def test_add_modify_delete_in_one_commit( self, git_repo: Path, tracker_config: dict, tmp_path: Path ) -> None: stations = git_repo / "Stations" / "PS101" stations.mkdir(parents=True) (stations / "pump2.xml").write_text("", encoding="utf-8") (git_repo / "README.md").write_text("changed\n", encoding="utf-8") (git_repo / "obsolete.xml").write_text("bye", encoding="utf-8") _git_out("add", "obsolete.xml", cwd=git_repo) _git_out("commit", "-m", "add obsolete", cwd=git_repo) (git_repo / "obsolete.xml").unlink() outcome = _run(tracker_config, tmp_path) assert outcome.commit_hash is not None assert outcome.change_count == 3 subjects = _commit_log(git_repo) assert subjects[0].startswith("SCADA automatic change capture - ") body = _git_out("log", "-1", "--format=%b", cwd=git_repo) assert "Stations/PS101/pump2.xml" in body assert "README.md" in body assert "obsolete.xml" in body assert "Tracker-Run: " in body assert "Added: 1" in body assert "Modified: 1" in body assert "Deleted: 1" in body # Author of the commit is the tracker identity (Section 17) author = _git_out("log", "-1", "--format=%an <%ae>", cwd=git_repo).strip() assert author == "SCADA Change Tracker " def test_sidecar_attribution_appears_in_commit( self, git_repo: Path, tracker_config: dict, tmp_path: Path ) -> None: (git_repo / "config.xml").write_text("", encoding="utf-8") (git_repo / "config.xml.metadata").write_text( 'last_modified_by=DOMAIN\\jsmith\n', encoding="utf-8" ) _run(tracker_config, tmp_path) body = _git_out("log", "-1", "--format=%b", cwd=git_repo) # One-line entry: letter ; path ; author ; local-time mtime assert " A ; config.xml ; DOMAIN\\jsmith ; " in body def test_file_mtime_appears_in_commit_local_time( self, git_repo: Path, tracker_config: dict, tmp_path: Path ) -> None: """Section 12 file_timestamp on the one-line entry (local time).""" import re as re_mod (git_repo / "config.xml").write_text("", encoding="utf-8") _run(tracker_config, tmp_path) body = _git_out("log", "-1", "--format=%b", cwd=git_repo) # A ; path ; author ; local-time mtime with +/-HH:MM offset assert re_mod.search( r"^ A ; config\.xml ; UNKNOWN ; " r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$", body, flags=re_mod.MULTILINE, ) def test_custom_separator_in_real_commit( self, git_repo: Path, tracker_config: dict, tmp_path: Path ) -> None: """commit.file_separator flows from configuration into the commit.""" tracker_config["commit"] = {"file_separator": "|"} (git_repo / "config.xml").write_text("", encoding="utf-8") _run(tracker_config, tmp_path) body = _git_out("log", "-1", "--format=%b", cwd=git_repo) assert " A | config.xml | UNKNOWN | " in body assert "; config.xml" not in body def test_excluded_files_not_committed( self, git_repo: Path, tracker_config: dict, tmp_path: Path ) -> None: (git_repo / "junk.log").write_text("noise", encoding="utf-8") (git_repo / "real.xml").write_text("", encoding="utf-8") outcome = _run(tracker_config, tmp_path) assert outcome.change_count == 1 status = _git_out("status", "--porcelain", cwd=git_repo) assert "junk.log" in status # still untracked, left alone assert "real.xml" not in status def test_dry_run_leaves_repo_untouched( self, git_repo: Path, tracker_config: dict, tmp_path: Path ) -> None: (git_repo / "new.xml").write_text("", encoding="utf-8") before = _git_out("status", "--porcelain", "-z", cwd=git_repo) outcome = _run(tracker_config, tmp_path, dry_run=True) assert outcome.dry_run assert outcome.commit_message is not None assert "new.xml" in outcome.commit_message after = _git_out("status", "--porcelain", "-z", cwd=git_repo) assert before == after assert len(_commit_log(git_repo)) == 1 # only the initial commit def test_unicode_and_spaces( self, git_repo: Path, tracker_config: dict, tmp_path: Path ) -> None: tricky = git_repo / "Station PS101" tricky.mkdir() (tricky / "räumliche Datei.xml").write_text("", encoding="utf-8") outcome = _run(tracker_config, tmp_path) assert outcome.change_count == 1 # ls-files -z output is NUL-separated raw UTF-8 (never C-quoted) raw = _git_out("ls-files", "-z", cwd=git_repo) files = [part for part in raw.split("\x00") if part] assert "Station PS101/räumliche Datei.xml" in files class TestExtraction: """Section 45: derived artifacts flow through the full collection.""" def _make_sqlite(self, git_repo: Path) -> Path: import sqlite3 history = git_repo / "history" history.mkdir(exist_ok=True) db = history / "alarms.sqlite" connection = sqlite3.connect(db) connection.execute("CREATE TABLE alarms (id INTEGER PRIMARY KEY, text TEXT)") connection.execute("INSERT INTO alarms VALUES (1, 'alpha')") connection.commit() connection.close() return db def _config_with_extraction(self, tracker_config: dict) -> dict: tracker_config["extraction"] = { "enabled": True, "output_root": "extracted", "format": "jsonl", "sources": [{"path": "history/alarms.sqlite"}], } tracker_config["files"]["exclude"].append("**/*.sqlite") return tracker_config def test_first_run_commits_artifacts( self, git_repo: Path, tracker_config: dict, tmp_path: Path ) -> None: self._make_sqlite(git_repo) config = self._config_with_extraction(tracker_config) outcome = _run(config, tmp_path) # artifacts only: raw DB excluded, no other changes assert outcome.change_count == 1 assert outcome.commit_hash is not None body = _git_out("log", "-1", "--format=%b", cwd=git_repo) assert "extracted/alarms/alarms.jsonl" in body tracked = _git_out("ls-files", "-z", cwd=git_repo) assert "alarms.sqlite" not in tracked # raw DB excluded content = (git_repo / "extracted" / "alarms" / "alarms.jsonl").read_text( encoding="utf-8" ) assert '{"id":1,"text":"alpha"}' in content def test_unchanged_db_no_second_commit( self, git_repo: Path, tracker_config: dict, tmp_path: Path ) -> None: self._make_sqlite(git_repo) config = self._config_with_extraction(tracker_config) _run(config, tmp_path) outcome = _run(config, tmp_path) assert outcome.change_count == 0 # FR-031: byte-identical artifacts assert outcome.commit_hash is None def test_changed_row_produces_artifact_commit( self, git_repo: Path, tracker_config: dict, tmp_path: Path ) -> None: import sqlite3 db = self._make_sqlite(git_repo) config = self._config_with_extraction(tracker_config) _run(config, tmp_path) connection = sqlite3.connect(db) connection.execute("INSERT INTO alarms VALUES (2, 'beta')") connection.commit() connection.close() outcome = _run(config, tmp_path) assert outcome.change_count == 1 body = _git_out("log", "-1", "--format=%b", cwd=git_repo) assert "M ; extracted/alarms/alarms.jsonl" in body def test_failed_source_skipped_with_warning_others_extracted( self, git_repo: Path, tracker_config: dict, tmp_path: Path ) -> None: """Section 45.7: per-source isolation - one bad file, others proceed.""" self._make_sqlite(git_repo) # A second, non-database source alongside the valid one (git_repo / "history" / "impostor.db").write_text( "definitely not a database", encoding="utf-8" ) config = self._config_with_extraction(tracker_config) config["extraction"]["sources"].append({"path": "history/impostor.db"}) config["files"]["exclude"].append("history/impostor.db") # raw file not tracked outcome = _run(config, tmp_path) # The valid source was still extracted and committed assert outcome.change_count == 1 assert outcome.commit_hash is not None body = _git_out("log", "-1", "--format=%b", cwd=git_repo) assert "extracted/alarms/alarms.jsonl" in body # Nothing was extracted for the bad source assert not (git_repo / "extracted" / "impostor").exists() def test_non_sqlite_file_not_committed_as_artifact( self, git_repo: Path, tracker_config: dict, tmp_path: Path ) -> None: """A non-database source is skipped, never dumped or committed.""" history = git_repo / "history" history.mkdir(exist_ok=True) (git_repo / "history" / "notadb.sqlite").write_text( "plain text wearing a database name", encoding="utf-8" ) config = self._config_with_extraction(tracker_config) config["extraction"]["sources"] = [{"path": "history/notadb.sqlite"}] outcome = _run(config, tmp_path) assert outcome.change_count == 0 # nothing extracted, nothing committed assert not (git_repo / "extracted" / "notadb").exists() def test_dry_run_skips_refresh( self, git_repo: Path, tracker_config: dict, tmp_path: Path, caplog ) -> None: self._make_sqlite(git_repo) config = self._config_with_extraction(tracker_config) before = _git_out("status", "--porcelain", "-z", cwd=git_repo) outcome = _run(config, tmp_path, dry_run=True) assert outcome.dry_run # Artifacts never created: the refresh is the step that writes them assert (git_repo / "extracted").exists() is False # tree untouched after = _git_out("status", "--porcelain", "-z", cwd=git_repo) 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("", 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 ) -> None: (git_repo / "staged.xml").write_text("", encoding="utf-8") _git_out("add", "staged.xml", cwd=git_repo) with pytest.raises(RepositoryError, match="staged changes"): _run(tracker_config, tmp_path) def test_unfinished_merge_state_aborts( self, git_repo: Path, tracker_config: dict, tmp_path: Path ) -> None: (git_repo / "conflict.txt").write_text("base", encoding="utf-8") _git_out("add", "conflict.txt", cwd=git_repo) _git_out("commit", "-m", "base", cwd=git_repo) _git_out("checkout", "-b", "feature", cwd=git_repo) (git_repo / "conflict.txt").write_text("feature", encoding="utf-8") _git_out("commit", "-am", "feature", cwd=git_repo) _git_out("checkout", "main", cwd=git_repo) (git_repo / "conflict.txt").write_text("main", encoding="utf-8") _git_out("commit", "-am", "main", cwd=git_repo) with contextlib.suppress(subprocess.CalledProcessError): _git_out("merge", "feature", cwd=git_repo) # expected conflict with pytest.raises(RepositoryError, match="MERGE_HEAD"): _run(tracker_config, tmp_path) def test_wrong_branch_aborts( self, git_repo: Path, tracker_config: dict, tmp_path: Path ) -> None: _git_out("checkout", "-b", "other-branch", cwd=git_repo) with pytest.raises(RepositoryError, match="configured branch"): _run(tracker_config, tmp_path) def test_forbidden_file_aborts_with_security_code( self, git_repo: Path, tracker_config: dict, tmp_path: Path ) -> None: (git_repo / "server.key").write_text("PRIVATE", encoding="utf-8") with pytest.raises(SecurityError): _run(tracker_config, tmp_path) # Exit code mapping (Section 25) assert int(SecurityError("x").exit_code) == ExitCode.SECURITY_VIOLATION # Nothing was committed assert len(_commit_log(git_repo)) == 1 class TestLockIntegration: def test_concurrent_run_blocked( self, git_repo: Path, tracker_config: dict, tmp_path: Path ) -> None: from git_auto_tracker.locking.repository_lock import RepositoryLock, default_lock_path _, controller = _make_controller(tracker_config, tmp_path) lock = RepositoryLock(default_lock_path(controller.config.repository_path)) lock.acquire() # A second execution attempts the same lock via the CLI path: second = RepositoryLock(default_lock_path(controller.config.repository_path)) with pytest.raises(TrackerError): second.acquire() lock.release() def test_lock_file_never_in_status( self, git_repo: Path, tracker_config: dict, tmp_path: Path ) -> None: (git_repo / "change.xml").write_text("", encoding="utf-8") _run(tracker_config, tmp_path) status = _git_out("status", "--porcelain", "--untracked-files=all", cwd=git_repo) assert "git-auto-tracker.lock" not in status