phase: 106_document_dates
Everything is verified green. Final report: **Phase 106 — Document dates (final verification pass; all 10 tasks already complete)** - Verified all phase artifacts: alembic `0020` (dev DB at `0020`), `doc_dates.py`, git `file_commit_dates`, importer `doc_dates_by_root`/`dates_updated`, both entry-point wirings, date APIs + tree `created_at`/`updated_at`, LLM surfaces (prompt block, `read` line 2, appended `ls` field), `apply_recency_boost` in `retrieve()`, UI columns/badge, admin editor, mock-LLM regex — all present and correct; no defects found, no fixes needed. - `uv run pytest --cov=app --cov-report=term-missing` → **2299 passed, TOTAL 99%** (>90% ✓) - `uv run pytest tests/e2e/test_document_dates.py -v --no-cov` → **6/6 passed** in isolation (DB up) - 12 regression E2E suites (retrieval_quality, whole_document_context, agent_document_tools, ls_tree_drilldown, read_truncation_cap, kb_tree, kb_tree_nav, document_viewer, edit_summaries, import_documents, sync_button, hidden_folders_toggle, smoke) → **all green in isolation** - `uv run ruff check .` → clean; `uv run pyright` → **0 errors, 0 warnings** **Completion criteria:** 1) non-null `created_at` + 0020 upgrade/downgrade on dev DB ✓ (real-Alembic integration tests) 2) sync refresh/older/manual-persists/content-reset/no sources_meta bump ✓ 3) zip/tar mtime + future→today ✓ 4) LLM date surfaces + cross-check ✓ 5) UI Created/Updated/badge positions ✓ 6) admin editor set+revert round-trip ✓ 7) old-correct-beats-new-similar (defaults & boost-off) + near-tie + `BOR_RECENCY_BOOST=0` byte-identical ✓ 8) full gate ✓ 9) commit/phase-move — left to harness per instructions. - **Notable:** recency default tuned 0.001 → **0.0007** (task 07 step 5 explicitly permits; measured margins recorded in `test_recency_boost.py` docstring). - **Next pending phase:** none — `todo/` holds only this phase.
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
"""Integration: phase 106 task 03 — ``file_commit_dates`` against real
|
||||
git scratch repos (D2/D10).
|
||||
|
||||
Builds scratch repositories with controlled ``GIT_COMMITTER_DATE``s
|
||||
(the 2026-09-13 verification recipe: file ``a.md`` committed once in
|
||||
2020, file ``b.md`` committed in 2020 and touched again in 2024, a
|
||||
``docs/deep.md`` subdirectory file committed once in 2020) and pins
|
||||
the VERIFIED checkout behavior:
|
||||
|
||||
* a LOCAL-PATH ``clone_or_pull`` keeps FULL history (git's own
|
||||
"--depth is ignored in local clones" warning — the ``--depth 1``
|
||||
flag stays, D10) → TRUE per-file last-commit dates (first-sighting
|
||||
wins: ``a.md`` 2020, ``b.md`` 2024, ``docs/deep.md`` 2020);
|
||||
* a shallow URL-transport clone (``file://``, made directly in this
|
||||
test — the test harness, not ``clone_or_pull``, makes this one) →
|
||||
the TIP commit's date for EVERY working-tree file (the
|
||||
shallow-boundary property, D10: uniform per repo, real across
|
||||
repos);
|
||||
* fail-soft: a directory without ``.git``, an empty repo (no
|
||||
commits), a git failure, and a malformed log output all yield
|
||||
``{}`` — a date walk must never break a sync (the importer, task
|
||||
04, falls back to file mtimes).
|
||||
|
||||
DB-free by design: ``file_commit_dates`` takes a path, no session.
|
||||
Skipped (not failed) on a machine without the git CLI (the
|
||||
``test_doc_drafts_api.py`` guard).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.git_sync import (
|
||||
GitSyncError,
|
||||
_parse_commit_dates, # pyright: ignore[reportPrivateUsage]
|
||||
clone_or_pull,
|
||||
file_commit_dates,
|
||||
)
|
||||
|
||||
|
||||
def _git_available() -> bool:
|
||||
try:
|
||||
proc = subprocess.run(["git", "--version"], capture_output=True, check=False)
|
||||
return proc.returncode == 0
|
||||
except (FileNotFoundError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
#: Real ``git`` in the test environment — skipped cleanly without it
|
||||
#: (the ``test_doc_drafts_api.py`` house pattern).
|
||||
GIT = _git_available()
|
||||
|
||||
pytestmark = pytest.mark.skipif(not GIT, reason="git CLI not available")
|
||||
|
||||
#: The two controlled commit dates (the 2026-09-13 verification recipe).
|
||||
DATE_A = datetime(2020, 1, 2, 3, 4, 6, tzinfo=UTC) # commit one (2020)
|
||||
DATE_B = datetime(2024, 6, 15, 10, 0, 0, tzinfo=UTC) # commit two = the tip (2024)
|
||||
|
||||
|
||||
def _git(cwd: Path, *argv: str, when: datetime | None = None) -> None:
|
||||
"""Run one git command for the test harness (fixture setup); a
|
||||
non-zero exit fails the fixture, not the test under test."""
|
||||
env = os.environ.copy()
|
||||
if when is not None:
|
||||
iso = when.isoformat()
|
||||
env["GIT_AUTHOR_DATE"] = iso
|
||||
env["GIT_COMMITTER_DATE"] = iso
|
||||
env["GIT_AUTHOR_NAME"] = "T"
|
||||
env["GIT_AUTHOR_EMAIL"] = "t@example.com"
|
||||
env["GIT_COMMITTER_NAME"] = "T"
|
||||
env["GIT_COMMITTER_EMAIL"] = "t@example.com"
|
||||
proc = subprocess.run(
|
||||
["git", *argv], cwd=cwd, env=env, capture_output=True, text=True, check=False
|
||||
)
|
||||
assert proc.returncode == 0, f"git {' '.join(argv)} failed: {proc.stderr}"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def scratch_repo(tmp_path: Path) -> Path:
|
||||
"""The 2026-09-13 recipe: commit one (2020-01-02) adds ``a.md``,
|
||||
``b.md``, ``docs/deep.md``; commit two (2024-06-15, the tip)
|
||||
touches ONLY ``b.md``."""
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
_git(repo, "init", "-q")
|
||||
_git(repo, "config", "user.email", "t@example.com")
|
||||
_git(repo, "config", "user.name", "T")
|
||||
_git(repo, "config", "commit.gpgsign", "false")
|
||||
(repo / "docs").mkdir()
|
||||
(repo / "a.md").write_text("# A\nstable since 2020\n", encoding="utf-8")
|
||||
(repo / "b.md").write_text("# B\nfirst version\n", encoding="utf-8")
|
||||
(repo / "docs" / "deep.md").write_text("# Deep\nalso 2020\n", encoding="utf-8")
|
||||
_git(repo, "add", "-A", when=DATE_A)
|
||||
_git(repo, "commit", "-qm", "one", when=DATE_A)
|
||||
(repo / "b.md").write_text("# B\nupdated 2024\n", encoding="utf-8")
|
||||
_git(repo, "add", "-A", when=DATE_B)
|
||||
_git(repo, "commit", "-qm", "two", when=DATE_B)
|
||||
return repo
|
||||
|
||||
|
||||
def test_local_clone_yields_true_per_file_dates(scratch_repo: Path, tmp_path: Path) -> None:
|
||||
"""(a) LOCAL-PATH ``clone_or_pull`` → full history (git warns
|
||||
"--depth is ignored in local clones" and does not shallow) → TRUE
|
||||
per-file last-commit dates: the first (newest) sighting of each
|
||||
path wins — ``b.md`` the 2024 touch, the rest the 2020 commit."""
|
||||
dest = tmp_path / "local"
|
||||
clone_or_pull(str(scratch_repo), dest)
|
||||
assert (dest / ".git").exists() # a real checkout
|
||||
assert file_commit_dates(dest) == {
|
||||
"a.md": DATE_A,
|
||||
"b.md": DATE_B, # touched again by the tip commit
|
||||
"docs/deep.md": DATE_A,
|
||||
}
|
||||
|
||||
|
||||
def test_shallow_file_clone_yields_tip_date_for_every_file(
|
||||
scratch_repo: Path, tmp_path: Path
|
||||
) -> None:
|
||||
"""(b) SHALLOW URL-TRANSPORT clone (``file://``, made directly in
|
||||
the test — D10): in a shallow clone git reports the TIP commit as
|
||||
every existing file's last commit (the shallow boundary is each
|
||||
file's history root) → EVERY working-tree file carries the tip
|
||||
date, uniform within the repo."""
|
||||
dest = tmp_path / "shallow"
|
||||
_git(tmp_path, "clone", "-q", "--depth", "1", f"file://{scratch_repo}", str(dest))
|
||||
assert file_commit_dates(dest) == {
|
||||
"a.md": DATE_B,
|
||||
"b.md": DATE_B,
|
||||
"docs/deep.md": DATE_B,
|
||||
}
|
||||
|
||||
|
||||
def test_directory_without_dotgit_fails_soft(tmp_path: Path) -> None:
|
||||
"""(c) a plain directory (no ``.git``) → ``git log`` exits
|
||||
non-zero → ``{}`` (fail-soft, no raise) — the importer falls back
|
||||
to file mtimes."""
|
||||
plain = tmp_path / "notarepo"
|
||||
plain.mkdir()
|
||||
(plain / "a.md").write_text("# A\nnot a git repo\n", encoding="utf-8")
|
||||
assert file_commit_dates(plain) == {}
|
||||
|
||||
|
||||
def test_nonexistent_directory_fails_soft(tmp_path: Path) -> None:
|
||||
"""(c) a missing checkout directory → ``{}`` without even
|
||||
invoking git (no raise)."""
|
||||
assert file_commit_dates(tmp_path / "gone") == {}
|
||||
|
||||
|
||||
def test_empty_repo_fails_soft(tmp_path: Path) -> None:
|
||||
"""(c) an initialized repo with NO commits → ``git log`` fails
|
||||
(nothing to log) → ``{}`` (a cloned-but-empty source must not
|
||||
break the sync)."""
|
||||
empty = tmp_path / "emptyrepo"
|
||||
empty.mkdir()
|
||||
_git(empty, "init", "-q")
|
||||
_git(empty, "config", "commit.gpgsign", "false")
|
||||
assert file_commit_dates(empty) == {}
|
||||
|
||||
|
||||
def test_git_error_fails_soft(
|
||||
scratch_repo: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""(c) a ``GitSyncError`` from the walk (git missing/failed) →
|
||||
``{}`` + a logged warning naming the fallback — the fail-soft
|
||||
contract (pinned)."""
|
||||
|
||||
def boom(argv: list[str], cwd: Path) -> str:
|
||||
raise GitSyncError("git log failed (exit 128): fatal: bad object")
|
||||
|
||||
monkeypatch.setattr("scripts.git_sync.run_git", boom)
|
||||
with caplog.at_level("WARNING"):
|
||||
assert file_commit_dates(scratch_repo) == {}
|
||||
assert any("file_commit_dates" in record.message for record in caplog.records)
|
||||
|
||||
|
||||
def test_malformed_log_output_fails_soft(
|
||||
scratch_repo: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""(c) ANY parse anomaly (a commit date ``fromisoformat`` cannot
|
||||
read) → ``{}`` (fail-soft) — the walk is all-or-nothing: a
|
||||
partially parsed date map would be worse than none."""
|
||||
monkeypatch.setattr(
|
||||
"scripts.git_sync.run_git",
|
||||
lambda argv, cwd: "@@not-a-date\nb.md\n",
|
||||
)
|
||||
assert file_commit_dates(scratch_repo) == {}
|
||||
|
||||
|
||||
# --- the pure parser (canned git output — no git, no DB) -------------------
|
||||
|
||||
|
||||
def test_parser_first_sighting_wins_newest_first() -> None:
|
||||
"""The walk is newest-first, so the FIRST sighting of a path is
|
||||
its last-commit date: ``b.md`` appears under both commits and keeps
|
||||
the 2024 (newest) date; the 2020 ``a.md`` keeps 2020. Blank lines
|
||||
(git's commit separators) are skipped."""
|
||||
output = "\n".join(
|
||||
[
|
||||
"@@2024-06-15T10:00:00+00:00",
|
||||
"",
|
||||
"b.md",
|
||||
"@@2020-01-02T03:04:06+00:00",
|
||||
"",
|
||||
"a.md",
|
||||
"b.md",
|
||||
]
|
||||
)
|
||||
assert _parse_commit_dates(output) == {"a.md": DATE_A, "b.md": DATE_B}
|
||||
|
||||
|
||||
def test_parser_normalizes_paths() -> None:
|
||||
"""Path lines are whitespace-split (defensively - git's name-only
|
||||
output is one path per line), backslash-normalized to ``/``, and a
|
||||
leading ``/`` is stripped (repo-relative POSIX keys); the line is
|
||||
stripped first."""
|
||||
output = "\n".join(
|
||||
[
|
||||
"@@2024-06-15T10:00:00+00:00",
|
||||
"",
|
||||
"docs\\deep.md",
|
||||
"/rooted.md",
|
||||
" padded.md ",
|
||||
]
|
||||
)
|
||||
assert _parse_commit_dates(output) == {
|
||||
"docs/deep.md": DATE_B,
|
||||
"rooted.md": DATE_B,
|
||||
"padded.md": DATE_B,
|
||||
}
|
||||
|
||||
|
||||
def test_parser_rejects_path_before_header() -> None:
|
||||
"""A path line before ANY commit header is a malformed walk →
|
||||
``ValueError`` (the caller's fail-soft path turns it into
|
||||
``{}``)."""
|
||||
with pytest.raises(ValueError, match="before any commit header"):
|
||||
_parse_commit_dates("stray.md\n@@2024-06-15T10:00:00+00:00\n")
|
||||
|
||||
|
||||
def test_parser_rejects_bad_date() -> None:
|
||||
"""A commit date ``fromisoformat`` cannot read → ``ValueError``
|
||||
(ISO-strict ``%cI`` always parses — this is the anomaly guard)."""
|
||||
with pytest.raises(ValueError):
|
||||
_parse_commit_dates("@@yesterday\nb.md\n")
|
||||
Reference in New Issue
Block a user