From 4c1957692d40a2f4d2edc65019cb9afe524653c9 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Wed, 7 Oct 2026 22:29:21 +0200 Subject: [PATCH] fix(doctor,scan): report ignored koru runtime files that are still tracked (ticket-442) A .gitignore entry does not untrack files committed earlier, so seven repositories keep .koru/event-store.jsonl and .koru/events/* tracked and every koru run dirties their primary checkout. check_gitignore now warns and scan_gitignore_drift suggests 'git rm --cached' for them. Co-Authored-By: Claude --- project/ticket-442/README.md | 21 +++++++ project/ticket-442/intent.json | 85 ++++++++++++++++++++++++++++ src/koru/doctor_project_health.py | 36 +++++++++++- src/koru/scan.py | 19 ++++++- tests/test_doctor_tracked_runtime.py | 50 ++++++++++++++++ 5 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 project/ticket-442/README.md create mode 100644 project/ticket-442/intent.json create mode 100644 tests/test_doctor_tracked_runtime.py diff --git a/project/ticket-442/README.md b/project/ticket-442/README.md new file mode 100644 index 00000000..af70e5e1 --- /dev/null +++ b/project/ticket-442/README.md @@ -0,0 +1,21 @@ +# Ticket 442: Detect tracked koru runtime files that .gitignore no longer excludes + +- **ID**: ticket-442 +- **Owner**: claude-code +- **Status**: IN_PROGRESS +- **Workflow state**: EDIT +- **Created**: 2026-10-07 + +## Goal and scope + +Seven repositories (semcod/prefact, repatch, tagi, nxdo, markpact/propact, subactor/orchestrator, coru-agent/coru-dev) keep `.koru/event-store.jsonl` and `.koru/events/*` tracked although `.gitignore` lists `.koru/`; they were committed before the ignore entry, so every koru run dirties the primary checkout. koru doctor/scan only checked for the `.gitignore` line. Report ignored-but-tracked runtime files instead. User authorization: investigate the last 8 hours with monag and reflex, propose prevention, then continue (`kontynuuj`). + +## Acceptance criteria + +- [ ] AC-01: `check_gitignore` warns and `scan_gitignore_drift` suggests untracking when ignored koru runtime files are still tracked; clean or non-Git projects pass. +- [ ] AC-02: Tests, Ruff and governance pass. + +## Tracking boundary + +This directory contains the minimal reviewed intent. Optional participant prose +and raw command logs are not required delivery output. diff --git a/project/ticket-442/intent.json b/project/ticket-442/intent.json new file mode 100644 index 00000000..b92e5e20 --- /dev/null +++ b/project/ticket-442/intent.json @@ -0,0 +1,85 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-442", + "summary": "Detect tracked koru runtime files that .gitignore no longer excludes", + "workstream": "application", + "classification": { + "kind": "BUG", + "priority": "P2", + "origin": "requested" + }, + "allowedPaths": [ + "project/ticket-442/**", + "TODO.md", + "project/TICKETS.md", + "src/koru/doctor_project_health.py", + "src/koru/scan.py", + "tests/test_doctor_tracked_runtime.py", + "tests/test_scan.py" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "06648f2a1bacdd4254669d522ff2b9471323b658", + "targetBranch": "main", + "outcome": "koru doctor and scan report runtime files under .koru/ and .planfile/.koru/ that are excluded by .gitignore but still tracked, so they stop dirtying checkouts unnoticed.", + "nonGoals": [ + "No automatic git rm in target repositories", + "No change to koru --init gitignore entries" + ], + "complexity": "S", + "estimatedMinutes": 30, + "budgets": { + "maxImplementationFiles": 5, + "maxAffectedComponents": 1, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "Add tracked_ignored_runtime_files() (git ls-files -ci --exclude-standard) to doctor_project_health; check_gitignore and scan_gitignore_drift report its result.", + "components": [ + { + "name": "project-health", + "paths": [ + "src/koru/doctor_project_health.py", + "src/koru/scan.py", + "tests/test_doctor_tracked_runtime.py", + "tests/test_scan.py" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": [], + "ui": { + "impact": "none", + "states": [], + "evidence": [] + }, + "rollback": "Revert the two source files and the new test." + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-01", + "commands": [ + "python3 -m pytest -q tests/test_doctor_tracked_runtime.py tests/test_scan.py" + ], + "evidence": "Tracked ignored runtime files produce WARN and a scan suggestion; untracked or non-Git projects pass." + }, + { + "criterion": "AC-02", + "commands": [ + "./project/governance-check.sh" + ], + "evidence": "Native governance validation passes." + } + ] + } +} diff --git a/src/koru/doctor_project_health.py b/src/koru/doctor_project_health.py index 2bd46d21..3e010b07 100644 --- a/src/koru/doctor_project_health.py +++ b/src/koru/doctor_project_health.py @@ -373,15 +373,45 @@ def check_policy_yaml(project: Path) -> tuple[str, str]: return PASS, "parses; loaded values match schema" +RUNTIME_PATHS = (".koru", ".planfile/.koru") + + +def tracked_ignored_runtime_files(project: Path) -> list[str]: + """Return koru runtime files Git still tracks although ``.gitignore`` excludes them. + + A ``.gitignore`` entry does not untrack files committed earlier, so they + keep dirtying the checkout on every koru run. Empty outside a Git checkout. + """ + try: + result = subprocess.run( + ["git", "-C", str(project), "ls-files", "-ci", "--exclude-standard", "--", *RUNTIME_PATHS], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return [] + if result.returncode != 0: + return [] + return [line for line in result.stdout.splitlines() if line] + + def check_gitignore(project: Path) -> tuple[str, str]: gi = project / ".gitignore" if not gi.exists(): return WARN, ".gitignore missing — runtime artefacts may be committed" text = gi.read_text(encoding="utf-8") needle = ".planfile/.koru/" - if any(line.strip() == needle for line in text.splitlines()): - return PASS, f"ignores {needle}" - return WARN, f".gitignore does not list {needle} — re-run `koru --init`" + if not any(line.strip() == needle for line in text.splitlines()): + return WARN, f".gitignore does not list {needle} — re-run `koru --init`" + tracked = tracked_ignored_runtime_files(project) + if tracked: + return WARN, ( + f"{len(tracked)} ignored koru runtime file(s) still tracked " + f"(e.g. {tracked[0]}) — untrack with `git rm --cached`" + ) + return PASS, f"ignores {needle}" def resolve_pytest_collect_timeout() -> float: diff --git a/src/koru/scan.py b/src/koru/scan.py index 8b2b2e7a..8e6f1402 100644 --- a/src/koru/scan.py +++ b/src/koru/scan.py @@ -39,6 +39,7 @@ from pathlib import Path from typing import Any +from koru.doctor_project_health import tracked_ignored_runtime_files from koru.fleet_admission import scan_admission from koru.scan_artifacts import ( _ANALYSIS_ARTIFACT_PATHS as _ANALYSIS_ARTIFACT_PATHS, # noqa: F401 @@ -448,7 +449,23 @@ def scan_gitignore_drift(project: Path) -> list[Suggestion]: except OSError: return [] if needle in text: - return [] + tracked = tracked_ignored_runtime_files(project) + if not tracked: + return [] + return [ + Suggestion( + signal="gitignore_drift", + title="Untrack koru runtime files excluded by `.gitignore`", + description=( + f"{len(tracked)} koru runtime file(s) are ignored but still tracked, " + "so every koru run dirties the checkout. Run " + "`git rm --cached` on them in a ticket commit." + ), + priority="low", + labels=("hygiene", "scan"), + files=tuple(tracked[:20]), + ), + ] return [ Suggestion( signal="gitignore_drift", diff --git a/tests/test_doctor_tracked_runtime.py b/tests/test_doctor_tracked_runtime.py new file mode 100644 index 00000000..e98748b9 --- /dev/null +++ b/tests/test_doctor_tracked_runtime.py @@ -0,0 +1,50 @@ +"""Ignored-but-tracked koru runtime files are reported (ticket-442).""" + +import subprocess +from pathlib import Path + +import pytest + +from koru.doctor_constants import PASS, WARN +from koru.doctor_project_health import check_gitignore, tracked_ignored_runtime_files +from koru.scan import scan_gitignore_drift + + +def _git(project: Path, *args: str) -> None: + subprocess.run(["git", "-C", str(project), *args], check=True, capture_output=True) + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + _git(tmp_path, "init", "-q") + _git(tmp_path, "config", "user.email", "t@example.invalid") + _git(tmp_path, "config", "user.name", "t") + (tmp_path / ".koru").mkdir() + (tmp_path / ".koru" / "event-store.jsonl").write_text("{}\n") + (tmp_path / "README.md").write_text("x\n") + _git(tmp_path, "add", "-f", ".") + _git(tmp_path, "commit", "-qm", "seed") + (tmp_path / ".gitignore").write_text(".planfile/.koru/\n.koru/\n") + return tmp_path + + +def test_tracked_runtime_file_is_reported(repo: Path) -> None: + assert tracked_ignored_runtime_files(repo) == [".koru/event-store.jsonl"] + status, detail = check_gitignore(repo) + assert status == WARN + assert "git rm --cached" in detail + [suggestion] = scan_gitignore_drift(repo) + assert suggestion.files == (".koru/event-store.jsonl",) + + +def test_untracked_runtime_passes(repo: Path) -> None: + _git(repo, "rm", "-q", "--cached", ".koru/event-store.jsonl") + assert tracked_ignored_runtime_files(repo) == [] + assert check_gitignore(repo)[0] == PASS + assert scan_gitignore_drift(repo) == [] + + +def test_outside_git_checkout_is_empty(tmp_path: Path) -> None: + (tmp_path / ".gitignore").write_text(".planfile/.koru/\n") + assert tracked_ignored_runtime_files(tmp_path) == [] + assert check_gitignore(tmp_path)[0] == PASS