From fbf4f86a09cba4de89b70c7231d79905bedb4cab Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Apr 2026 17:39:28 +0000 Subject: [PATCH 1/3] feat: add repo file freshness script Co-authored-by: buddingengineers12345 --- freshness_ignore.json | 7 + scripts/repo_file_freshness.py | 396 +++++++++++++++++++++++++++++++++ 2 files changed, 403 insertions(+) create mode 100644 freshness_ignore.json create mode 100644 scripts/repo_file_freshness.py diff --git a/freshness_ignore.json b/freshness_ignore.json new file mode 100644 index 0000000..4652b22 --- /dev/null +++ b/freshness_ignore.json @@ -0,0 +1,7 @@ +{ + "files": [], + "directories": [], + "extensions": [], + "patterns": [] +} + diff --git a/scripts/repo_file_freshness.py b/scripts/repo_file_freshness.py new file mode 100644 index 0000000..4ab1e09 --- /dev/null +++ b/scripts/repo_file_freshness.py @@ -0,0 +1,396 @@ +#!/usr/bin/env python3 +"""Generate repository file freshness reports using Git history. + +This script: +- Lists only Git-tracked files (via ``git ls-files``). +- Computes each file's last update timestamp (via ``git log -1``). +- Classifies files into green/yellow/red/blue using age thresholds and an ignore config. +- Writes: + - ``docs/repo_file_status_report.md`` (dashboard) + - ``file_freshness.json`` (per-file details) + - ``freshness_summary.json`` (counts + optional badge metadata) + +Design notes: +- Git is the source of truth (no filesystem mtimes). +- A single file failing Git history lookup must not fail the whole run. +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from dataclasses import dataclass +from datetime import datetime, timezone +from fnmatch import fnmatch +from pathlib import Path +from typing import Any, Literal, Protocol, cast + +Status = Literal["green", "yellow", "red", "blue"] + + +class _Args(Protocol): + repo_root: str + ignore_config: str + output_markdown: str + output_details_json: str + output_summary_json: str + + +@dataclass(frozen=True) +class IgnoreConfig: + """Ignore configuration loaded from ``freshness_ignore.json``.""" + + files: frozenset[str] + directories: tuple[str, ...] + extensions: frozenset[str] + patterns: tuple[str, ...] + + @classmethod + def empty(cls) -> IgnoreConfig: + return cls(files=frozenset(), directories=tuple(), extensions=frozenset(), patterns=tuple()) + + +def _now_utc() -> datetime: + return datetime.now(timezone.utc) + + +def _run_git(root: Path, args: list[str]) -> subprocess.CompletedProcess[str] | None: + """Run a git command, returning the process or None if git is missing.""" + try: + return subprocess.run( + ["git", "-C", str(root), *args], + check=False, + capture_output=True, + text=True, + ) + except FileNotFoundError: + return None + + +def _resolve_repo_root(user_repo_root: str) -> Path: + """Resolve repository root, falling back to the provided path.""" + candidate = Path(user_repo_root).resolve() + proc = _run_git(candidate, ["rev-parse", "--show-toplevel"]) + if proc is None or proc.returncode != 0: + return candidate + return Path(proc.stdout.strip()).resolve() + + +def _git_ls_files(root: Path) -> list[str]: + """Return git-tracked files as POSIX-relative paths.""" + proc = _run_git(root, ["ls-files"]) + if proc is None or proc.returncode != 0: + return [] + paths = [line.strip() for line in proc.stdout.splitlines() if line.strip()] + # Normalize to POSIX-style even on Windows runners (Git returns / already, but keep consistent). + return [p.replace("\\", "/") for p in paths] + + +def _git_last_commit_iso(root: Path, rel_path: str) -> str | None: + """Return last commit timestamp for a file as an ISO-8601 string, or None.""" + proc = _run_git(root, ["log", "-1", "--format=%cI", "--", rel_path]) + if proc is None or proc.returncode != 0: + return None + value = proc.stdout.strip() + return value or None + + +def _parse_git_iso_datetime(value: str) -> datetime | None: + """Parse Git's ISO timestamp (e.g. 2026-04-08T12:34:56+00:00).""" + try: + dt = datetime.fromisoformat(value) + except ValueError: + return None + if dt.tzinfo is None: + # Defensive: treat naive as UTC. + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def _age_days(now: datetime, commit_iso: str | None) -> int | None: + if commit_iso is None: + return None + dt = _parse_git_iso_datetime(commit_iso) + if dt is None: + return None + delta = now - dt + # Floor to integer days. + return max(0, int(delta.total_seconds() // 86400)) + + +def _normalize_dir_prefix(s: str) -> str: + s2 = s.strip().replace("\\", "/") + if not s2: + return "" + if not s2.endswith("/"): + s2 += "/" + return s2 + + +def _normalize_extension(s: str) -> str: + s2 = s.strip().lower() + if not s2: + return "" + if not s2.startswith("."): + s2 = "." + s2 + return s2 + + +def load_ignore_config(path: Path) -> IgnoreConfig: + """Load ignore configuration from JSON, falling back to empty on errors.""" + if not path.is_file(): + return IgnoreConfig.empty() + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + print(f"[freshness] Warning: failed to read {path}: {exc}", file=sys.stderr) + return IgnoreConfig.empty() + if not isinstance(raw, dict): + print(f"[freshness] Warning: ignore config root must be an object: {path}", file=sys.stderr) + return IgnoreConfig.empty() + + def get_list(key: str) -> list[str]: + v = raw.get(key, []) + if isinstance(v, list) and all(isinstance(x, str) for x in v): + return cast(list[str], v) + print(f"[freshness] Warning: ignore key {key!r} must be a list[str]: {path}", file=sys.stderr) + return [] + + files = frozenset(x.strip().replace("\\", "/") for x in get_list("files") if x.strip()) + directories = tuple( + d for d in (_normalize_dir_prefix(x) for x in get_list("directories")) if d + ) + extensions = frozenset( + e for e in (_normalize_extension(x) for x in get_list("extensions")) if e + ) + patterns = tuple(x.strip().replace("\\", "/") for x in get_list("patterns") if x.strip()) + + # Keep deterministic ordering for tuple fields. + return IgnoreConfig( + files=files, + directories=tuple(sorted(set(directories), key=str.lower)), + extensions=extensions, + patterns=tuple(sorted(set(patterns), key=str.lower)), + ) + + +def ignored_status(rel_path: str, ignore: IgnoreConfig) -> bool: + """Return True if the path should be ignored (blue), per ignore priority.""" + p = rel_path.replace("\\", "/") + + # 1) Exact file match + if p in ignore.files: + return True + + # 2) Directory prefix match + for d in ignore.directories: + if p.startswith(d): + return True + + # 3) Extension match + ext = Path(p).suffix.lower() + if ext and ext in ignore.extensions: + return True + + # 4) Glob pattern match (relative paths) + for pat in ignore.patterns: + if fnmatch(p, pat): + return True + + return False + + +def classify(age_days: int | None, *, is_ignored: bool) -> Status: + """Classify into one of the four statuses.""" + if is_ignored: + return "blue" + # Edge case: missing commit timestamp. Fold into red for the four-color contract. + if age_days is None: + return "red" + if age_days <= 7: + return "green" + if age_days <= 30: + return "yellow" + return "red" + + +def _sort_key_age_desc(item: dict[str, Any]) -> tuple[int, str]: + # age_days descending, None last; stable tie-breaker by path. + age = item.get("age_days") + if isinstance(age, int): + return (-age, cast(str, item.get("file", ""))) + return (10**9, cast(str, item.get("file", ""))) + + +def write_json(path: Path, data: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + text = json.dumps(data, indent=2, sort_keys=True) + "\n" + _ = path.write_text(text, encoding="utf-8") + + +def generate_markdown(now: datetime, summary: dict[str, int], items: list[dict[str, Any]]) -> str: + updated = now.strftime("%Y-%m-%d %H:%M:%S UTC") + lines: list[str] = [] + lines.append("# Repository File Status Report") + lines.append("") + lines.append(f"Last updated: **{updated}**") + lines.append("") + lines.append("## Summary") + lines.append("") + lines.append(f"- 🟢 Green: **{summary['green']}**") + lines.append(f"- 🟡 Yellow: **{summary['yellow']}**") + lines.append(f"- 🔴 Red: **{summary['red']}**") + lines.append(f"- 🔵 Blue: **{summary['blue']}**") + lines.append("") + + groups: dict[Status, list[dict[str, Any]]] = {"green": [], "yellow": [], "red": [], "blue": []} + for it in items: + groups[cast(Status, it["status"])].append(it) + + def render_section(title: str, status: Status, *, show_age: bool) -> None: + lines.append(f"## {title}") + lines.append("") + if not groups[status]: + lines.append("_None._") + lines.append("") + return + for it in groups[status]: + path = cast(str, it["file"]) + age = it.get("age_days") + if show_age: + if isinstance(age, int): + lines.append(f"- `{path}` — **{age}** days") + else: + lines.append(f"- `{path}` — _unknown age_") + else: + lines.append(f"- `{path}`") + lines.append("") + + render_section("🟢 Green (recent)", "green", show_age=True) + render_section("🟡 Yellow (moderate)", "yellow", show_age=True) + render_section("🔴 Red (stale)", "red", show_age=True) + render_section("🔵 Blue (ignored)", "blue", show_age=False) + + return "\n".join(lines) + "\n" + + +def build_badge_fields(summary: dict[str, int]) -> dict[str, str]: + """Return optional badge fields based on the worst present status.""" + red = summary["red"] + yellow = summary["yellow"] + green = summary["green"] + blue = summary["blue"] + total = red + yellow + green + blue + + if total == 0: + return {"label": "freshness", "message": "no files", "color": "lightgrey"} + if red > 0: + return {"label": "freshness", "message": f"{red} stale", "color": "red"} + if yellow > 0: + return {"label": "freshness", "message": f"{yellow} moderate", "color": "yellow"} + return {"label": "freshness", "message": f"{green} recent", "color": "brightgreen"} + + +def main() -> int: + parser = argparse.ArgumentParser(description="Generate repository file freshness reports.") + _ = parser.add_argument( + "--repo-root", + default=".", + help="Repository root (defaults to current directory; resolved via git when possible).", + ) + _ = parser.add_argument( + "--ignore-config", + default="freshness_ignore.json", + help=( + "Path to ignore config JSON with keys: files, directories, extensions, patterns " + "(all lists of strings)." + ), + ) + _ = parser.add_argument( + "--output-markdown", + default="docs/repo_file_status_report.md", + help="Path to write Markdown dashboard.", + ) + _ = parser.add_argument( + "--output-details-json", + default="file_freshness.json", + help="Path to write per-file JSON details.", + ) + _ = parser.add_argument( + "--output-summary-json", + default="freshness_summary.json", + help="Path to write summary JSON counts (plus optional badge fields).", + ) + args = cast(_Args, cast(object, parser.parse_args())) + + root = _resolve_repo_root(args.repo_root) + ignore = load_ignore_config((root / args.ignore_config).resolve()) + now = _now_utc() + + files = _git_ls_files(root) + items: list[dict[str, Any]] = [] + counts: dict[Status, int] = {"green": 0, "yellow": 0, "red": 0, "blue": 0} + + for rel_path in files: + try: + is_ignored = ignored_status(rel_path, ignore) + commit_iso = _git_last_commit_iso(root, rel_path) + age = _age_days(now, commit_iso) + status = classify(age, is_ignored=is_ignored) + counts[status] += 1 + items.append( + { + "file": rel_path, + "last_commit": commit_iso, + "age_days": age, + "status": status, + } + ) + except Exception as exc: # noqa: BLE001 - reliability: never fail per-file. + print(f"[freshness] Warning: failed processing {rel_path}: {exc}", file=sys.stderr) + status = "red" + counts[status] += 1 + items.append({"file": rel_path, "last_commit": None, "age_days": None, "status": status}) + + # Sorting rules: green/yellow/red by age desc; blue alpha. + for s in ("green", "yellow", "red"): + items_s = [it for it in items if it["status"] == s] + items_s.sort(key=_sort_key_age_desc) + # Replace in-place order by concatenation later. + blue_items = [it for it in items if it["status"] == "blue"] + blue_items.sort(key=lambda d: cast(str, d.get("file", "")).lower()) + ordered = ( + sorted((it for it in items if it["status"] == "green"), key=_sort_key_age_desc) + + sorted((it for it in items if it["status"] == "yellow"), key=_sort_key_age_desc) + + sorted((it for it in items if it["status"] == "red"), key=_sort_key_age_desc) + + blue_items + ) + + summary: dict[str, int] = { + "green": counts["green"], + "yellow": counts["yellow"], + "red": counts["red"], + "blue": counts["blue"], + } + summary_out: dict[str, Any] = dict(summary) + summary_out.update(build_badge_fields(summary)) + + md_text = generate_markdown(now, summary, ordered) + + # Always generate outputs. + write_json((root / args.output_details_json).resolve(), ordered) + write_json((root / args.output_summary_json).resolve(), summary_out) + out_md_path = (root / args.output_markdown).resolve() + out_md_path.parent.mkdir(parents=True, exist_ok=True) + _ = out_md_path.write_text(md_text, encoding="utf-8") + + return 0 + + +if __name__ == "__main__": + # Ensure a stable timezone in environments where TZ may be set oddly. + os.environ.setdefault("TZ", "UTC") + raise SystemExit(main()) From 5ee7ed7de2e5d30ba3f71b64fd53f708ea2d47c4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Apr 2026 17:46:02 +0000 Subject: [PATCH 2/3] feat: add freshness reports and automation Co-authored-by: buddingengineers12345 --- .github/workflows/file-freshness.yml | 56 ++ .gitignore | 9 +- docs/repo_file_status_report.md | 191 ++++++ file_freshness.json | 992 +++++++++++++++++++++++++++ freshness_summary.json | 9 + justfile | 8 + scripts/repo_file_freshness.py | 17 +- tests/test_repo_file_freshness.py | 138 ++++ 8 files changed, 1418 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/file-freshness.yml create mode 100644 docs/repo_file_status_report.md create mode 100644 file_freshness.json create mode 100644 freshness_summary.json create mode 100644 tests/test_repo_file_freshness.py diff --git a/.github/workflows/file-freshness.yml b/.github/workflows/file-freshness.yml new file mode 100644 index 0000000..2859a60 --- /dev/null +++ b/.github/workflows/file-freshness.yml @@ -0,0 +1,56 @@ +# Repository file freshness workflow +# +# This workflow intentionally triggers only on a schedule and manual dispatch. +# It commits generated artifacts back to the repository, and we avoid `on: push` +# to prevent feedback loops. +name: File Freshness + +on: + schedule: + - cron: "30 3 * * *" # Daily at 03:30 UTC + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: file-freshness-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + freshness: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@v8.0.0 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.11 + + - name: Sync dependencies + run: uv sync --frozen --extra dev + + - name: Generate freshness artifacts + run: uv run --active python scripts/repo_file_freshness.py + + - name: Configure git author + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Commit and push (if changed) + run: | + git add docs/repo_file_status_report.md file_freshness.json freshness_summary.json freshness_ignore.json + if git diff --staged --quiet; then + echo "No freshness changes to commit." + exit 0 + fi + git commit -m "chore: update file freshness report" + git push diff --git a/.gitignore b/.gitignore index d13e69b..81af506 100644 --- a/.gitignore +++ b/.gitignore @@ -77,12 +77,19 @@ instance/ # Sphinx documentation docs/_build/ -docs/ +docs/* + +# Allow the freshness dashboard report to be committed. +!docs/ +!docs/repo_file_status_report.md # Copier template ships MkDocs sources under template/docs/ (must not match the rule above). !template/docs/ !template/docs/** +# Repo freshness dashboard (generated + committed) +!docs/repo_file_status_report.md + # PyBuilder target/ diff --git a/docs/repo_file_status_report.md b/docs/repo_file_status_report.md new file mode 100644 index 0000000..644ab36 --- /dev/null +++ b/docs/repo_file_status_report.md @@ -0,0 +1,191 @@ +# Repository File Status Report + +Last updated: **2026-04-08 17:42:57 UTC** + +## Summary + +- 🟢 Green: **165** +- 🟡 Yellow: **0** +- 🔴 Red: **0** +- 🔵 Blue: **0** + +## 🟢 Green (recent) + +- `.vscode/extensions.json` — **7** days +- `.vscode/launch.json` — **7** days +- `.vscode/settings.json` — **7** days +- `LICENSE` — **7** days +- `template/.github/CODE_OF_CONDUCT.md.jinja` — **7** days +- `template/.github/ISSUE_TEMPLATE/bug_report.md.jinja` — **7** days +- `template/.github/ISSUE_TEMPLATE/config.yml.jinja` — **7** days +- `template/.github/ISSUE_TEMPLATE/feature_request.md.jinja` — **7** days +- `template/.github/PULL_REQUEST_TEMPLATE.md.jinja` — **7** days +- `template/.vscode/extensions.json.jinja` — **7** days +- `template/.vscode/launch.json.jinja` — **7** days +- `template/.vscode/settings.json.jinja` — **7** days +- `template/LICENSE.jinja` — **7** days +- `.claude/commands/ci.md` — **6** days +- `.claude/commands/generate.md` — **6** days +- `.claude/commands/test.md` — **6** days +- `template/tests/{{ package_name }}/test_core.py.jinja` — **6** days +- `.claude/commands/coverage.md` — **5** days +- `.claude/commands/dependency-check.md` — **5** days +- `.claude/commands/docs-check.md` — **5** days +- `.claude/commands/release.md` — **5** days +- `.claude/commands/review.md` — **5** days +- `.claude/commands/standards.md` — **5** days +- `.claude/commands/update-claude-md.md` — **5** days +- `.claude/commands/validate-release.md` — **5** days +- `README.md` — **5** days +- `scripts/bump_version.py` — **5** days +- `scripts/update_files.sh` — **5** days +- `template/.claude/commands/ci.md` — **5** days +- `template/.claude/commands/coverage.md.jinja` — **5** days +- `template/.claude/commands/docs-check.md.jinja` — **5** days +- `template/.claude/commands/generate.md` — **5** days +- `template/.claude/commands/guided-template-update.md.jinja` — **5** days +- `template/.claude/commands/release.md.jinja` — **5** days +- `template/.claude/commands/review.md.jinja` — **5** days +- `template/.claude/commands/standards.md.jinja` — **5** days +- `template/.claude/commands/test.md` — **5** days +- `template/.github/CODEOWNERS.jinja` — **5** days +- `template/src/{{ package_name }}/common/__init__.py.jinja` — **5** days +- `template/src/{{ package_name }}/common/decorators.py.jinja` — **5** days +- `template/src/{{ package_name }}/common/file_manager.py.jinja` — **5** days +- `template/src/{{ package_name }}/common/utils.py.jinja` — **5** days +- `template/tests/__init__.py.jinja` — **5** days +- `template/tests/{{ package_name }}/__init__.py.jinja` — **5** days +- `template/{{_copier_conf.answers_file}}.jinja` — **5** days +- `template/src/{{ package_name }}/core.py.jinja` — **4** days +- `.github/dependabot.yml` — **3** days +- `.github/workflows/dependency-review.yml` — **3** days +- `.github/workflows/lint.yml` — **3** days +- `.github/workflows/release.yml` — **3** days +- `.github/workflows/stale.yml` — **3** days +- `template/.github/workflows/ci.yml.jinja` — **3** days +- `template/.github/workflows/dependency-review.yml.jinja` — **3** days +- `template/.github/workflows/docs.yml.jinja` — **3** days +- `template/.github/workflows/lint.yml.jinja` — **3** days +- `template/.github/workflows/pre-commit-update.yml.jinja` — **3** days +- `.claude/hooks/README.md` — **2** days +- `.claude/hooks/post-bash-pr-created.sh` — **2** days +- `.claude/hooks/post-edit-copier-migration.sh` — **2** days +- `.claude/hooks/post-edit-jinja.sh` — **2** days +- `.claude/hooks/post-edit-markdown.sh` — **2** days +- `.claude/hooks/post-edit-python.sh` — **2** days +- `.claude/hooks/post-edit-template-mirror.sh` — **2** days +- `.claude/hooks/pre-bash-block-no-verify.sh` — **2** days +- `.claude/hooks/pre-bash-commit-quality.sh` — **2** days +- `.claude/hooks/pre-bash-git-push-reminder.sh` — **2** days +- `.claude/hooks/pre-compact-save-state.sh` — **2** days +- `.claude/hooks/pre-config-protection.sh` — **2** days +- `.claude/hooks/pre-protect-uv-lock.sh` — **2** days +- `.claude/hooks/pre-suggest-compact.sh` — **2** days +- `.claude/hooks/pre-write-doc-file-warning.sh` — **2** days +- `.claude/hooks/pre-write-jinja-syntax.sh` — **2** days +- `.claude/hooks/session-start-bootstrap.sh` — **2** days +- `.claude/hooks/stop-cost-tracker.sh` — **2** days +- `.claude/hooks/stop-desktop-notify.sh` — **2** days +- `.claude/hooks/stop-evaluate-session.sh` — **2** days +- `.claude/hooks/stop-session-end.sh` — **2** days +- `.claude/rules/README.md` — **2** days +- `.claude/rules/bash/coding-style.md` — **2** days +- `.claude/rules/bash/security.md` — **2** days +- `.claude/rules/common/code-review.md` — **2** days +- `.claude/rules/common/coding-style.md` — **2** days +- `.claude/rules/common/development-workflow.md` — **2** days +- `.claude/rules/common/git-workflow.md` — **2** days +- `.claude/rules/common/security.md` — **2** days +- `.claude/rules/common/testing.md` — **2** days +- `.claude/rules/jinja/coding-style.md` — **2** days +- `.claude/rules/jinja/testing.md` — **2** days +- `.claude/rules/markdown/conventions.md` — **2** days +- `.claude/rules/python/coding-style.md` — **2** days +- `.claude/rules/python/hooks.md` — **2** days +- `.claude/rules/python/patterns.md` — **2** days +- `.claude/rules/python/security.md` — **2** days +- `.claude/rules/python/testing.md` — **2** days +- `.claude/rules/yaml/conventions.md` — **2** days +- `.claude/settings.json` — **2** days +- `CLAUDE.md` — **2** days +- `template/.claude/hooks/README.md` — **2** days +- `template/.claude/hooks/post-edit-markdown.sh` — **2** days +- `template/.claude/hooks/post-edit-python.sh` — **2** days +- `template/.claude/hooks/pre-bash-block-no-verify.sh` — **2** days +- `template/.claude/hooks/pre-bash-commit-quality.sh` — **2** days +- `template/.claude/hooks/pre-bash-git-push-reminder.sh` — **2** days +- `template/.claude/hooks/pre-config-protection.sh` — **2** days +- `template/.claude/hooks/pre-protect-uv-lock.sh` — **2** days +- `template/.claude/rules/README.md` — **2** days +- `template/.claude/rules/bash/coding-style.md` — **2** days +- `template/.claude/rules/bash/security.md` — **2** days +- `template/.claude/rules/common/code-review.md` — **2** days +- `template/.claude/rules/common/coding-style.md` — **2** days +- `template/.claude/rules/common/development-workflow.md` — **2** days +- `template/.claude/rules/common/git-workflow.md` — **2** days +- `template/.claude/rules/common/security.md` — **2** days +- `template/.claude/rules/common/testing.md` — **2** days +- `template/.claude/rules/markdown/conventions.md` — **2** days +- `template/.claude/rules/python/coding-style.md.jinja` — **2** days +- `template/.claude/rules/python/hooks.md` — **2** days +- `template/.claude/rules/python/patterns.md.jinja` — **2** days +- `template/.claude/rules/python/security.md` — **2** days +- `template/.claude/rules/python/testing.md` — **2** days +- `template/.claude/settings.json` — **2** days +- `template/.github/workflows/security.yml.jinja` — **2** days +- `.github/labeler.yml` — **1** days +- `.github/renovate.json` — **1** days +- `.pre-commit-config.yaml` — **1** days +- `.secrets.baseline` — **1** days +- `justfile` — **1** days +- `template/.github/renovate.json.jinja` — **1** days +- `template/.pre-commit-config.yaml.jinja` — **1** days +- `template/.secrets.baseline` — **1** days +- `template/CLAUDE.md.jinja` — **1** days +- `template/CONTRIBUTING.md.jinja` — **1** days +- `template/README.md.jinja` — **1** days +- `template/SECURITY.md.jinja` — **1** days +- `template/cliff.toml.jinja` — **1** days +- `template/docs/ci.md.jinja` — **1** days +- `template/docs/index.md.jinja` — **1** days +- `template/mkdocs.yml.jinja` — **1** days +- `template/src/{{ package_name }}/__init__.py.jinja` — **1** days +- `template/src/{{ package_name }}/cli.py.jinja` — **1** days +- `template/tests/test_imports.py.jinja` — **1** days +- `.claude/rules/copier/template-conventions.md` — **0** days +- `.github/workflows/labeler.yml` — **0** days +- `.github/workflows/pre-commit-update.yml` — **0** days +- `.github/workflows/security.yml` — **0** days +- `.github/workflows/sync-skip-if-exists.yml` — **0** days +- `.github/workflows/tests.yml` — **0** days +- `.gitignore` — **0** days +- `copier.yml` — **0** days +- `env.example` — **0** days +- `freshness_ignore.json` — **0** days +- `pyproject.toml` — **0** days +- `scripts/repo_file_freshness.py` — **0** days +- `scripts/sync_skip_if_exists.py` — **0** days +- `template/.github/workflows/release.yml.jinja` — **0** days +- `template/.gitignore.jinja` — **0** days +- `template/env.example.jinja` — **0** days +- `template/justfile.jinja` — **0** days +- `template/pyproject.toml.jinja` — **0** days +- `template/src/{{ package_name }}/common/bump_version.py.jinja` — **0** days +- `template/src/{{ package_name }}/common/logging_manager.py.jinja` — **0** days +- `template/tests/conftest.py.jinja` — **0** days +- `template/tests/{{ package_name }}/test_support.py.jinja` — **0** days +- `tests/test_template.py` — **0** days +- `uv.lock` — **0** days + +## 🟡 Yellow (moderate) + +_None._ + +## 🔴 Red (stale) + +_None._ + +## 🔵 Blue (ignored) + +_None._ + diff --git a/file_freshness.json b/file_freshness.json new file mode 100644 index 0000000..be3d3f5 --- /dev/null +++ b/file_freshness.json @@ -0,0 +1,992 @@ +[ + { + "age_days": 7, + "file": ".vscode/extensions.json", + "last_commit": "2026-04-01T16:16:11+02:00", + "status": "green" + }, + { + "age_days": 7, + "file": ".vscode/launch.json", + "last_commit": "2026-04-01T16:16:11+02:00", + "status": "green" + }, + { + "age_days": 7, + "file": ".vscode/settings.json", + "last_commit": "2026-04-01T16:16:11+02:00", + "status": "green" + }, + { + "age_days": 7, + "file": "LICENSE", + "last_commit": "2026-04-01T16:16:11+02:00", + "status": "green" + }, + { + "age_days": 7, + "file": "template/.github/CODE_OF_CONDUCT.md.jinja", + "last_commit": "2026-04-01T16:16:11+02:00", + "status": "green" + }, + { + "age_days": 7, + "file": "template/.github/ISSUE_TEMPLATE/bug_report.md.jinja", + "last_commit": "2026-04-01T18:12:59+02:00", + "status": "green" + }, + { + "age_days": 7, + "file": "template/.github/ISSUE_TEMPLATE/config.yml.jinja", + "last_commit": "2026-04-01T18:12:59+02:00", + "status": "green" + }, + { + "age_days": 7, + "file": "template/.github/ISSUE_TEMPLATE/feature_request.md.jinja", + "last_commit": "2026-04-01T18:12:59+02:00", + "status": "green" + }, + { + "age_days": 7, + "file": "template/.github/PULL_REQUEST_TEMPLATE.md.jinja", + "last_commit": "2026-04-01T16:16:11+02:00", + "status": "green" + }, + { + "age_days": 7, + "file": "template/.vscode/extensions.json.jinja", + "last_commit": "2026-04-01T16:16:11+02:00", + "status": "green" + }, + { + "age_days": 7, + "file": "template/.vscode/launch.json.jinja", + "last_commit": "2026-04-01T16:16:11+02:00", + "status": "green" + }, + { + "age_days": 7, + "file": "template/.vscode/settings.json.jinja", + "last_commit": "2026-04-01T16:16:11+02:00", + "status": "green" + }, + { + "age_days": 7, + "file": "template/LICENSE.jinja", + "last_commit": "2026-04-01T16:16:11+02:00", + "status": "green" + }, + { + "age_days": 6, + "file": ".claude/commands/ci.md", + "last_commit": "2026-04-01T23:54:33+00:00", + "status": "green" + }, + { + "age_days": 6, + "file": ".claude/commands/generate.md", + "last_commit": "2026-04-01T23:49:24+02:00", + "status": "green" + }, + { + "age_days": 6, + "file": ".claude/commands/test.md", + "last_commit": "2026-04-01T23:49:24+02:00", + "status": "green" + }, + { + "age_days": 6, + "file": "template/tests/{{ package_name }}/test_core.py.jinja", + "last_commit": "2026-04-02T00:35:48+02:00", + "status": "green" + }, + { + "age_days": 5, + "file": ".claude/commands/coverage.md", + "last_commit": "2026-04-03T16:37:11+02:00", + "status": "green" + }, + { + "age_days": 5, + "file": ".claude/commands/dependency-check.md", + "last_commit": "2026-04-03T17:52:15+02:00", + "status": "green" + }, + { + "age_days": 5, + "file": ".claude/commands/docs-check.md", + "last_commit": "2026-04-03T16:37:11+02:00", + "status": "green" + }, + { + "age_days": 5, + "file": ".claude/commands/release.md", + "last_commit": "2026-04-03T16:55:34+02:00", + "status": "green" + }, + { + "age_days": 5, + "file": ".claude/commands/review.md", + "last_commit": "2026-04-03T16:37:11+02:00", + "status": "green" + }, + { + "age_days": 5, + "file": ".claude/commands/standards.md", + "last_commit": "2026-04-03T16:37:11+02:00", + "status": "green" + }, + { + "age_days": 5, + "file": ".claude/commands/update-claude-md.md", + "last_commit": "2026-04-03T16:37:11+02:00", + "status": "green" + }, + { + "age_days": 5, + "file": ".claude/commands/validate-release.md", + "last_commit": "2026-04-03T17:52:15+02:00", + "status": "green" + }, + { + "age_days": 5, + "file": "README.md", + "last_commit": "2026-04-03T12:43:10+02:00", + "status": "green" + }, + { + "age_days": 5, + "file": "scripts/bump_version.py", + "last_commit": "2026-04-02T19:00:55+00:00", + "status": "green" + }, + { + "age_days": 5, + "file": "scripts/update_files.sh", + "last_commit": "2026-04-02T22:06:25+02:00", + "status": "green" + }, + { + "age_days": 5, + "file": "template/.claude/commands/ci.md", + "last_commit": "2026-04-02T20:14:16+02:00", + "status": "green" + }, + { + "age_days": 5, + "file": "template/.claude/commands/coverage.md.jinja", + "last_commit": "2026-04-03T16:37:11+02:00", + "status": "green" + }, + { + "age_days": 5, + "file": "template/.claude/commands/docs-check.md.jinja", + "last_commit": "2026-04-03T16:37:11+02:00", + "status": "green" + }, + { + "age_days": 5, + "file": "template/.claude/commands/generate.md", + "last_commit": "2026-04-02T20:14:16+02:00", + "status": "green" + }, + { + "age_days": 5, + "file": "template/.claude/commands/guided-template-update.md.jinja", + "last_commit": "2026-04-03T17:52:15+02:00", + "status": "green" + }, + { + "age_days": 5, + "file": "template/.claude/commands/release.md.jinja", + "last_commit": "2026-04-03T16:55:34+02:00", + "status": "green" + }, + { + "age_days": 5, + "file": "template/.claude/commands/review.md.jinja", + "last_commit": "2026-04-03T16:37:11+02:00", + "status": "green" + }, + { + "age_days": 5, + "file": "template/.claude/commands/standards.md.jinja", + "last_commit": "2026-04-03T16:37:11+02:00", + "status": "green" + }, + { + "age_days": 5, + "file": "template/.claude/commands/test.md", + "last_commit": "2026-04-02T20:14:16+02:00", + "status": "green" + }, + { + "age_days": 5, + "file": "template/.github/CODEOWNERS.jinja", + "last_commit": "2026-04-02T22:06:25+02:00", + "status": "green" + }, + { + "age_days": 5, + "file": "template/src/{{ package_name }}/common/__init__.py.jinja", + "last_commit": "2026-04-02T19:04:46+00:00", + "status": "green" + }, + { + "age_days": 5, + "file": "template/src/{{ package_name }}/common/decorators.py.jinja", + "last_commit": "2026-04-02T19:04:46+00:00", + "status": "green" + }, + { + "age_days": 5, + "file": "template/src/{{ package_name }}/common/file_manager.py.jinja", + "last_commit": "2026-04-02T19:04:46+00:00", + "status": "green" + }, + { + "age_days": 5, + "file": "template/src/{{ package_name }}/common/utils.py.jinja", + "last_commit": "2026-04-02T20:19:59+00:00", + "status": "green" + }, + { + "age_days": 5, + "file": "template/tests/__init__.py.jinja", + "last_commit": "2026-04-02T22:06:25+02:00", + "status": "green" + }, + { + "age_days": 5, + "file": "template/tests/{{ package_name }}/__init__.py.jinja", + "last_commit": "2026-04-02T22:06:25+02:00", + "status": "green" + }, + { + "age_days": 5, + "file": "template/{{_copier_conf.answers_file}}.jinja", + "last_commit": "2026-04-02T20:14:16+02:00", + "status": "green" + }, + { + "age_days": 4, + "file": "template/src/{{ package_name }}/core.py.jinja", + "last_commit": "2026-04-04T00:09:05+02:00", + "status": "green" + }, + { + "age_days": 3, + "file": ".github/dependabot.yml", + "last_commit": "2026-04-05T15:07:34+02:00", + "status": "green" + }, + { + "age_days": 3, + "file": ".github/workflows/dependency-review.yml", + "last_commit": "2026-04-05T18:30:54+02:00", + "status": "green" + }, + { + "age_days": 3, + "file": ".github/workflows/lint.yml", + "last_commit": "2026-04-05T18:30:54+02:00", + "status": "green" + }, + { + "age_days": 3, + "file": ".github/workflows/release.yml", + "last_commit": "2026-04-05T18:30:54+02:00", + "status": "green" + }, + { + "age_days": 3, + "file": ".github/workflows/stale.yml", + "last_commit": "2026-04-05T18:30:54+02:00", + "status": "green" + }, + { + "age_days": 3, + "file": "template/.github/workflows/ci.yml.jinja", + "last_commit": "2026-04-05T18:30:54+02:00", + "status": "green" + }, + { + "age_days": 3, + "file": "template/.github/workflows/dependency-review.yml.jinja", + "last_commit": "2026-04-05T18:30:54+02:00", + "status": "green" + }, + { + "age_days": 3, + "file": "template/.github/workflows/docs.yml.jinja", + "last_commit": "2026-04-05T18:30:54+02:00", + "status": "green" + }, + { + "age_days": 3, + "file": "template/.github/workflows/lint.yml.jinja", + "last_commit": "2026-04-05T18:30:54+02:00", + "status": "green" + }, + { + "age_days": 3, + "file": "template/.github/workflows/pre-commit-update.yml.jinja", + "last_commit": "2026-04-05T18:30:54+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/hooks/README.md", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/hooks/post-bash-pr-created.sh", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/hooks/post-edit-copier-migration.sh", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/hooks/post-edit-jinja.sh", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/hooks/post-edit-markdown.sh", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/hooks/post-edit-python.sh", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/hooks/post-edit-template-mirror.sh", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/hooks/pre-bash-block-no-verify.sh", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/hooks/pre-bash-commit-quality.sh", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/hooks/pre-bash-git-push-reminder.sh", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/hooks/pre-compact-save-state.sh", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/hooks/pre-config-protection.sh", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/hooks/pre-protect-uv-lock.sh", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/hooks/pre-suggest-compact.sh", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/hooks/pre-write-doc-file-warning.sh", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/hooks/pre-write-jinja-syntax.sh", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/hooks/session-start-bootstrap.sh", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/hooks/stop-cost-tracker.sh", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/hooks/stop-desktop-notify.sh", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/hooks/stop-evaluate-session.sh", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/hooks/stop-session-end.sh", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/rules/README.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/rules/bash/coding-style.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/rules/bash/security.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/rules/common/code-review.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/rules/common/coding-style.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/rules/common/development-workflow.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/rules/common/git-workflow.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/rules/common/security.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/rules/common/testing.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/rules/jinja/coding-style.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/rules/jinja/testing.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/rules/markdown/conventions.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/rules/python/coding-style.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/rules/python/hooks.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/rules/python/patterns.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/rules/python/security.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/rules/python/testing.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/rules/yaml/conventions.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": ".claude/settings.json", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": "CLAUDE.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": "template/.claude/hooks/README.md", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": "template/.claude/hooks/post-edit-markdown.sh", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": "template/.claude/hooks/post-edit-python.sh", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": "template/.claude/hooks/pre-bash-block-no-verify.sh", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": "template/.claude/hooks/pre-bash-commit-quality.sh", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": "template/.claude/hooks/pre-bash-git-push-reminder.sh", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": "template/.claude/hooks/pre-config-protection.sh", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": "template/.claude/hooks/pre-protect-uv-lock.sh", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": "template/.claude/rules/README.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": "template/.claude/rules/bash/coding-style.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": "template/.claude/rules/bash/security.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": "template/.claude/rules/common/code-review.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": "template/.claude/rules/common/coding-style.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": "template/.claude/rules/common/development-workflow.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": "template/.claude/rules/common/git-workflow.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": "template/.claude/rules/common/security.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": "template/.claude/rules/common/testing.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": "template/.claude/rules/markdown/conventions.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": "template/.claude/rules/python/coding-style.md.jinja", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": "template/.claude/rules/python/hooks.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": "template/.claude/rules/python/patterns.md.jinja", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": "template/.claude/rules/python/security.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": "template/.claude/rules/python/testing.md", + "last_commit": "2026-04-06T10:56:27+00:00", + "status": "green" + }, + { + "age_days": 2, + "file": "template/.claude/settings.json", + "last_commit": "2026-04-06T12:48:12+02:00", + "status": "green" + }, + { + "age_days": 2, + "file": "template/.github/workflows/security.yml.jinja", + "last_commit": "2026-04-06T17:19:49+02:00", + "status": "green" + }, + { + "age_days": 1, + "file": ".github/labeler.yml", + "last_commit": "2026-04-07T13:31:18+02:00", + "status": "green" + }, + { + "age_days": 1, + "file": ".github/renovate.json", + "last_commit": "2026-04-07T13:26:04+02:00", + "status": "green" + }, + { + "age_days": 1, + "file": ".pre-commit-config.yaml", + "last_commit": "2026-04-07T13:26:04+02:00", + "status": "green" + }, + { + "age_days": 1, + "file": ".secrets.baseline", + "last_commit": "2026-04-07T13:26:04+02:00", + "status": "green" + }, + { + "age_days": 1, + "file": "justfile", + "last_commit": "2026-04-07T13:06:28+00:00", + "status": "green" + }, + { + "age_days": 1, + "file": "template/.github/renovate.json.jinja", + "last_commit": "2026-04-07T13:26:04+02:00", + "status": "green" + }, + { + "age_days": 1, + "file": "template/.pre-commit-config.yaml.jinja", + "last_commit": "2026-04-07T13:26:04+02:00", + "status": "green" + }, + { + "age_days": 1, + "file": "template/.secrets.baseline", + "last_commit": "2026-04-07T13:26:04+02:00", + "status": "green" + }, + { + "age_days": 1, + "file": "template/CLAUDE.md.jinja", + "last_commit": "2026-04-07T13:26:04+02:00", + "status": "green" + }, + { + "age_days": 1, + "file": "template/CONTRIBUTING.md.jinja", + "last_commit": "2026-04-07T13:26:04+02:00", + "status": "green" + }, + { + "age_days": 1, + "file": "template/README.md.jinja", + "last_commit": "2026-04-07T13:37:48+02:00", + "status": "green" + }, + { + "age_days": 1, + "file": "template/SECURITY.md.jinja", + "last_commit": "2026-04-07T13:26:04+02:00", + "status": "green" + }, + { + "age_days": 1, + "file": "template/cliff.toml.jinja", + "last_commit": "2026-04-07T13:26:04+02:00", + "status": "green" + }, + { + "age_days": 1, + "file": "template/docs/ci.md.jinja", + "last_commit": "2026-04-07T13:31:18+02:00", + "status": "green" + }, + { + "age_days": 1, + "file": "template/docs/index.md.jinja", + "last_commit": "2026-04-07T13:31:18+02:00", + "status": "green" + }, + { + "age_days": 1, + "file": "template/mkdocs.yml.jinja", + "last_commit": "2026-04-07T13:26:04+02:00", + "status": "green" + }, + { + "age_days": 1, + "file": "template/src/{{ package_name }}/__init__.py.jinja", + "last_commit": "2026-04-07T13:26:04+02:00", + "status": "green" + }, + { + "age_days": 1, + "file": "template/src/{{ package_name }}/cli.py.jinja", + "last_commit": "2026-04-07T13:26:04+02:00", + "status": "green" + }, + { + "age_days": 1, + "file": "template/tests/test_imports.py.jinja", + "last_commit": "2026-04-07T13:26:04+02:00", + "status": "green" + }, + { + "age_days": 0, + "file": ".claude/rules/copier/template-conventions.md", + "last_commit": "2026-04-08T18:35:21+02:00", + "status": "green" + }, + { + "age_days": 0, + "file": ".github/workflows/labeler.yml", + "last_commit": "2026-04-08T10:27:54+00:00", + "status": "green" + }, + { + "age_days": 0, + "file": ".github/workflows/pre-commit-update.yml", + "last_commit": "2026-04-08T10:27:54+00:00", + "status": "green" + }, + { + "age_days": 0, + "file": ".github/workflows/security.yml", + "last_commit": "2026-04-08T10:27:54+00:00", + "status": "green" + }, + { + "age_days": 0, + "file": ".github/workflows/sync-skip-if-exists.yml", + "last_commit": "2026-04-08T10:27:54+00:00", + "status": "green" + }, + { + "age_days": 0, + "file": ".github/workflows/tests.yml", + "last_commit": "2026-04-08T10:27:54+00:00", + "status": "green" + }, + { + "age_days": 0, + "file": ".gitignore", + "last_commit": "2026-04-08T08:22:29+02:00", + "status": "green" + }, + { + "age_days": 0, + "file": "copier.yml", + "last_commit": "2026-04-08T18:35:21+02:00", + "status": "green" + }, + { + "age_days": 0, + "file": "env.example", + "last_commit": "2026-04-08T08:22:29+02:00", + "status": "green" + }, + { + "age_days": 0, + "file": "freshness_ignore.json", + "last_commit": "2026-04-08T17:39:28+00:00", + "status": "green" + }, + { + "age_days": 0, + "file": "pyproject.toml", + "last_commit": "2026-04-08T18:35:21+02:00", + "status": "green" + }, + { + "age_days": 0, + "file": "scripts/repo_file_freshness.py", + "last_commit": "2026-04-08T17:39:28+00:00", + "status": "green" + }, + { + "age_days": 0, + "file": "scripts/sync_skip_if_exists.py", + "last_commit": "2026-04-08T18:35:21+02:00", + "status": "green" + }, + { + "age_days": 0, + "file": "template/.github/workflows/release.yml.jinja", + "last_commit": "2026-04-08T18:35:21+02:00", + "status": "green" + }, + { + "age_days": 0, + "file": "template/.gitignore.jinja", + "last_commit": "2026-04-08T08:22:29+02:00", + "status": "green" + }, + { + "age_days": 0, + "file": "template/env.example.jinja", + "last_commit": "2026-04-08T08:22:29+02:00", + "status": "green" + }, + { + "age_days": 0, + "file": "template/justfile.jinja", + "last_commit": "2026-04-08T18:35:21+02:00", + "status": "green" + }, + { + "age_days": 0, + "file": "template/pyproject.toml.jinja", + "last_commit": "2026-04-08T18:35:21+02:00", + "status": "green" + }, + { + "age_days": 0, + "file": "template/src/{{ package_name }}/common/bump_version.py.jinja", + "last_commit": "2026-04-08T18:35:21+02:00", + "status": "green" + }, + { + "age_days": 0, + "file": "template/src/{{ package_name }}/common/logging_manager.py.jinja", + "last_commit": "2026-04-08T18:35:21+02:00", + "status": "green" + }, + { + "age_days": 0, + "file": "template/tests/conftest.py.jinja", + "last_commit": "2026-04-08T18:35:21+02:00", + "status": "green" + }, + { + "age_days": 0, + "file": "template/tests/{{ package_name }}/test_support.py.jinja", + "last_commit": "2026-04-08T18:35:21+02:00", + "status": "green" + }, + { + "age_days": 0, + "file": "tests/test_template.py", + "last_commit": "2026-04-08T18:35:21+02:00", + "status": "green" + }, + { + "age_days": 0, + "file": "uv.lock", + "last_commit": "2026-04-08T10:27:45+00:00", + "status": "green" + } +] diff --git a/freshness_summary.json b/freshness_summary.json new file mode 100644 index 0000000..d938b18 --- /dev/null +++ b/freshness_summary.json @@ -0,0 +1,9 @@ +{ + "blue": 0, + "color": "brightgreen", + "green": 165, + "label": "freshness", + "message": "165 recent", + "red": 0, + "yellow": 0 +} diff --git a/justfile b/justfile index d8dce07..e6e1958 100644 --- a/justfile +++ b/justfile @@ -187,3 +187,11 @@ doctor: @echo "=== Project ===" @echo "Repo: python_project_template" @echo "Python: >= 3.11" + +# ------------------------------------------------------------------------- +# Repo automation +# ------------------------------------------------------------------------- + +# Generate repo freshness dashboard + JSON artifacts +freshness: + @uv run --active python scripts/repo_file_freshness.py diff --git a/scripts/repo_file_freshness.py b/scripts/repo_file_freshness.py index 4ab1e09..db780b4 100644 --- a/scripts/repo_file_freshness.py +++ b/scripts/repo_file_freshness.py @@ -57,6 +57,21 @@ def _now_utc() -> datetime: return datetime.now(timezone.utc) +def _now_utc_from_env() -> datetime: + """Return a deterministic 'now' when FRESHNESS_NOW_ISO is set (for tests).""" + raw = os.environ.get("FRESHNESS_NOW_ISO") + if not raw: + return _now_utc() + dt = _parse_git_iso_datetime(raw) + if dt is None: + print( + f"[freshness] Warning: invalid FRESHNESS_NOW_ISO (expected ISO-8601): {raw!r}", + file=sys.stderr, + ) + return _now_utc() + return dt + + def _run_git(root: Path, args: list[str]) -> subprocess.CompletedProcess[str] | None: """Run a git command, returning the process or None if git is missing.""" try: @@ -328,7 +343,7 @@ def main() -> int: root = _resolve_repo_root(args.repo_root) ignore = load_ignore_config((root / args.ignore_config).resolve()) - now = _now_utc() + now = _now_utc_from_env() files = _git_ls_files(root) items: list[dict[str, Any]] = [] diff --git a/tests/test_repo_file_freshness.py b/tests/test_repo_file_freshness.py new file mode 100644 index 0000000..993ddf6 --- /dev/null +++ b/tests/test_repo_file_freshness.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + + +def run_command( + cmd: list[str], + cwd: Path, + *, + check: bool = True, + extra_env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + env = dict(os.environ) + if extra_env: + env.update(extra_env) + return subprocess.run(cmd, cwd=cwd, check=check, capture_output=True, text=True, env=env) + + +def git_init(repo: Path) -> None: + run_command(["git", "init"], cwd=repo) + run_command(["git", "config", "user.name", "Test User"], cwd=repo) + run_command(["git", "config", "user.email", "test@example.com"], cwd=repo) + + +def git_commit(repo: Path, message: str, *, commit_date_iso: str) -> None: + env = {"GIT_AUTHOR_DATE": commit_date_iso, "GIT_COMMITTER_DATE": commit_date_iso} + run_command(["git", "add", "-A"], cwd=repo, extra_env=env) + run_command(["git", "commit", "-m", message], cwd=repo, extra_env=env) + + +def write_ignore(repo: Path, data: object) -> None: + (repo / "freshness_ignore.json").write_text(json.dumps(data) + "\n", encoding="utf-8") + + +def run_script(repo: Path) -> None: + script = Path(__file__).resolve().parent.parent / "scripts" / "repo_file_freshness.py" + run_command( + [sys.executable, str(script), "--repo-root", str(repo)], + cwd=repo, + extra_env={"FRESHNESS_NOW_ISO": "2026-04-08T00:00:00+00:00"}, + ) + + +def load_details(repo: Path) -> list[dict[str, object]]: + raw = json.loads((repo / "file_freshness.json").read_text(encoding="utf-8")) + assert isinstance(raw, list) + return raw + + +def by_file(items: list[dict[str, object]]) -> dict[str, dict[str, object]]: + out: dict[str, dict[str, object]] = {} + for it in items: + out[str(it["file"])] = it + return out + + +def test_classification_green_yellow_red(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + git_init(repo) + + (repo / "recent.txt").write_text("recent\n", encoding="utf-8") + git_commit(repo, "add recent", commit_date_iso="2026-04-06T00:00:00+00:00") + + (repo / "mid.txt").write_text("mid\n", encoding="utf-8") + git_commit(repo, "add mid", commit_date_iso="2026-03-20T00:00:00+00:00") + + (repo / "old.txt").write_text("old\n", encoding="utf-8") + git_commit(repo, "add old", commit_date_iso="2025-12-01T00:00:00+00:00") + + run_script(repo) + items = by_file(load_details(repo)) + assert items["recent.txt"]["status"] == "green" + assert items["mid.txt"]["status"] == "yellow" + assert items["old.txt"]["status"] == "red" + + +def test_ignore_priority_exact_then_dir_then_ext_then_pattern(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + git_init(repo) + + (repo / "src").mkdir() + (repo / "src" / "main.py").write_text("print('x')\n", encoding="utf-8") + (repo / "build").mkdir() + (repo / "build" / "out.js").write_text("console.log('x')\n", encoding="utf-8") + (repo / "docs.md").write_text("# doc\n", encoding="utf-8") + (repo / "exact.txt").write_text("x\n", encoding="utf-8") + + git_commit(repo, "add files", commit_date_iso="2025-01-01T00:00:00+00:00") + + write_ignore( + repo, + { + "files": ["exact.txt"], + "directories": ["build/"], + "extensions": [".md"], + "patterns": ["src/*.py"], + }, + ) + + run_script(repo) + items = by_file(load_details(repo)) + assert items["exact.txt"]["status"] == "blue" + assert items["build/out.js"]["status"] == "blue" + assert items["docs.md"]["status"] == "blue" + assert items["src/main.py"]["status"] == "blue" + + +def test_invalid_ignore_config_is_graceful(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + git_init(repo) + (repo / "a.txt").write_text("a\n", encoding="utf-8") + git_commit(repo, "add a", commit_date_iso="2025-01-01T00:00:00+00:00") + + (repo / "freshness_ignore.json").write_text("{not-json}\n", encoding="utf-8") + run_script(repo) + + items = by_file(load_details(repo)) + assert items["a.txt"]["status"] in {"green", "yellow", "red", "blue"} + + +def test_empty_repo_generates_outputs(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + git_init(repo) + + run_script(repo) + assert (repo / "file_freshness.json").is_file() + assert (repo / "freshness_summary.json").is_file() + assert (repo / "docs" / "repo_file_status_report.md").is_file() From c650881274ee46685f97814934dabe2e04d28558 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Apr 2026 17:52:45 +0000 Subject: [PATCH 3/3] chore: stabilize copier tests and lint freshness script Co-authored-by: buddingengineers12345 --- docs/repo_file_status_report.md | 11 ++++++-- file_freshness.json | 46 +++++++++++++++++++++++++------ freshness_summary.json | 4 +-- scripts/repo_file_freshness.py | 30 ++++++++++---------- tests/test_repo_file_freshness.py | 2 -- tests/test_template.py | 36 ++++++++++++++++++++---- 6 files changed, 92 insertions(+), 37 deletions(-) diff --git a/docs/repo_file_status_report.md b/docs/repo_file_status_report.md index 644ab36..abd34e8 100644 --- a/docs/repo_file_status_report.md +++ b/docs/repo_file_status_report.md @@ -1,10 +1,10 @@ # Repository File Status Report -Last updated: **2026-04-08 17:42:57 UTC** +Last updated: **2026-04-08 17:52:36 UTC** ## Summary -- 🟢 Green: **165** +- 🟢 Green: **170** - 🟡 Yellow: **0** - 🔴 Red: **0** - 🔵 Blue: **0** @@ -137,7 +137,6 @@ Last updated: **2026-04-08 17:42:57 UTC** - `.github/renovate.json` — **1** days - `.pre-commit-config.yaml` — **1** days - `.secrets.baseline` — **1** days -- `justfile` — **1** days - `template/.github/renovate.json.jinja` — **1** days - `template/.pre-commit-config.yaml.jinja` — **1** days - `template/.secrets.baseline` — **1** days @@ -153,6 +152,7 @@ Last updated: **2026-04-08 17:42:57 UTC** - `template/src/{{ package_name }}/cli.py.jinja` — **1** days - `template/tests/test_imports.py.jinja` — **1** days - `.claude/rules/copier/template-conventions.md` — **0** days +- `.github/workflows/file-freshness.yml` — **0** days - `.github/workflows/labeler.yml` — **0** days - `.github/workflows/pre-commit-update.yml` — **0** days - `.github/workflows/security.yml` — **0** days @@ -160,8 +160,12 @@ Last updated: **2026-04-08 17:42:57 UTC** - `.github/workflows/tests.yml` — **0** days - `.gitignore` — **0** days - `copier.yml` — **0** days +- `docs/repo_file_status_report.md` — **0** days - `env.example` — **0** days +- `file_freshness.json` — **0** days - `freshness_ignore.json` — **0** days +- `freshness_summary.json` — **0** days +- `justfile` — **0** days - `pyproject.toml` — **0** days - `scripts/repo_file_freshness.py` — **0** days - `scripts/sync_skip_if_exists.py` — **0** days @@ -174,6 +178,7 @@ Last updated: **2026-04-08 17:42:57 UTC** - `template/src/{{ package_name }}/common/logging_manager.py.jinja` — **0** days - `template/tests/conftest.py.jinja` — **0** days - `template/tests/{{ package_name }}/test_support.py.jinja` — **0** days +- `tests/test_repo_file_freshness.py` — **0** days - `tests/test_template.py` — **0** days - `uv.lock` — **0** days diff --git a/file_freshness.json b/file_freshness.json index be3d3f5..8b71fd8 100644 --- a/file_freshness.json +++ b/file_freshness.json @@ -755,12 +755,6 @@ "last_commit": "2026-04-07T13:26:04+02:00", "status": "green" }, - { - "age_days": 1, - "file": "justfile", - "last_commit": "2026-04-07T13:06:28+00:00", - "status": "green" - }, { "age_days": 1, "file": "template/.github/renovate.json.jinja", @@ -851,6 +845,12 @@ "last_commit": "2026-04-08T18:35:21+02:00", "status": "green" }, + { + "age_days": 0, + "file": ".github/workflows/file-freshness.yml", + "last_commit": "2026-04-08T17:46:02+00:00", + "status": "green" + }, { "age_days": 0, "file": ".github/workflows/labeler.yml", @@ -884,7 +884,7 @@ { "age_days": 0, "file": ".gitignore", - "last_commit": "2026-04-08T08:22:29+02:00", + "last_commit": "2026-04-08T17:46:02+00:00", "status": "green" }, { @@ -893,18 +893,42 @@ "last_commit": "2026-04-08T18:35:21+02:00", "status": "green" }, + { + "age_days": 0, + "file": "docs/repo_file_status_report.md", + "last_commit": "2026-04-08T17:46:02+00:00", + "status": "green" + }, { "age_days": 0, "file": "env.example", "last_commit": "2026-04-08T08:22:29+02:00", "status": "green" }, + { + "age_days": 0, + "file": "file_freshness.json", + "last_commit": "2026-04-08T17:46:02+00:00", + "status": "green" + }, { "age_days": 0, "file": "freshness_ignore.json", "last_commit": "2026-04-08T17:39:28+00:00", "status": "green" }, + { + "age_days": 0, + "file": "freshness_summary.json", + "last_commit": "2026-04-08T17:46:02+00:00", + "status": "green" + }, + { + "age_days": 0, + "file": "justfile", + "last_commit": "2026-04-08T17:46:02+00:00", + "status": "green" + }, { "age_days": 0, "file": "pyproject.toml", @@ -914,7 +938,7 @@ { "age_days": 0, "file": "scripts/repo_file_freshness.py", - "last_commit": "2026-04-08T17:39:28+00:00", + "last_commit": "2026-04-08T17:46:02+00:00", "status": "green" }, { @@ -977,6 +1001,12 @@ "last_commit": "2026-04-08T18:35:21+02:00", "status": "green" }, + { + "age_days": 0, + "file": "tests/test_repo_file_freshness.py", + "last_commit": "2026-04-08T17:46:02+00:00", + "status": "green" + }, { "age_days": 0, "file": "tests/test_template.py", diff --git a/freshness_summary.json b/freshness_summary.json index d938b18..99c6f14 100644 --- a/freshness_summary.json +++ b/freshness_summary.json @@ -1,9 +1,9 @@ { "blue": 0, "color": "brightgreen", - "green": 165, + "green": 170, "label": "freshness", - "message": "165 recent", + "message": "170 recent", "red": 0, "yellow": 0 } diff --git a/scripts/repo_file_freshness.py b/scripts/repo_file_freshness.py index db780b4..105acc0 100644 --- a/scripts/repo_file_freshness.py +++ b/scripts/repo_file_freshness.py @@ -23,7 +23,7 @@ import subprocess import sys from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from fnmatch import fnmatch from pathlib import Path from typing import Any, Literal, Protocol, cast @@ -50,11 +50,11 @@ class IgnoreConfig: @classmethod def empty(cls) -> IgnoreConfig: - return cls(files=frozenset(), directories=tuple(), extensions=frozenset(), patterns=tuple()) + return cls(files=frozenset(), directories=(), extensions=frozenset(), patterns=()) def _now_utc() -> datetime: - return datetime.now(timezone.utc) + return datetime.now(UTC) def _now_utc_from_env() -> datetime: @@ -121,8 +121,8 @@ def _parse_git_iso_datetime(value: str) -> datetime | None: return None if dt.tzinfo is None: # Defensive: treat naive as UTC. - return dt.replace(tzinfo=timezone.utc) - return dt.astimezone(timezone.utc) + return dt.replace(tzinfo=UTC) + return dt.astimezone(UTC) def _age_days(now: datetime, commit_iso: str | None) -> int | None: @@ -171,13 +171,13 @@ def get_list(key: str) -> list[str]: v = raw.get(key, []) if isinstance(v, list) and all(isinstance(x, str) for x in v): return cast(list[str], v) - print(f"[freshness] Warning: ignore key {key!r} must be a list[str]: {path}", file=sys.stderr) + print( + f"[freshness] Warning: ignore key {key!r} must be a list[str]: {path}", file=sys.stderr + ) return [] files = frozenset(x.strip().replace("\\", "/") for x in get_list("files") if x.strip()) - directories = tuple( - d for d in (_normalize_dir_prefix(x) for x in get_list("directories")) if d - ) + directories = tuple(d for d in (_normalize_dir_prefix(x) for x in get_list("directories")) if d) extensions = frozenset( e for e in (_normalize_extension(x) for x in get_list("extensions")) if e ) @@ -211,11 +211,7 @@ def ignored_status(rel_path: str, ignore: IgnoreConfig) -> bool: return True # 4) Glob pattern match (relative paths) - for pat in ignore.patterns: - if fnmatch(p, pat): - return True - - return False + return any(fnmatch(p, pat) for pat in ignore.patterns) def classify(age_days: int | None, *, is_ignored: bool) -> Status: @@ -364,11 +360,13 @@ def main() -> int: "status": status, } ) - except Exception as exc: # noqa: BLE001 - reliability: never fail per-file. + except Exception as exc: # reliability: never fail per-file. print(f"[freshness] Warning: failed processing {rel_path}: {exc}", file=sys.stderr) status = "red" counts[status] += 1 - items.append({"file": rel_path, "last_commit": None, "age_days": None, "status": status}) + items.append( + {"file": rel_path, "last_commit": None, "age_days": None, "status": status} + ) # Sorting rules: green/yellow/red by age desc; blue alpha. for s in ("green", "yellow", "red"): diff --git a/tests/test_repo_file_freshness.py b/tests/test_repo_file_freshness.py index 993ddf6..2d7eed2 100644 --- a/tests/test_repo_file_freshness.py +++ b/tests/test_repo_file_freshness.py @@ -6,8 +6,6 @@ import sys from pathlib import Path -import pytest - def run_command( cmd: list[str], diff --git a/tests/test_template.py b/tests/test_template.py index 5aa3b1b..6504ff1 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -138,12 +138,14 @@ def copy_with_data( data: Mapping of Copier variable names to values to pass via ``--data``. skip_tasks: If ``True``, skip post-generation tasks (default). """ + template_repo = Path(__file__).resolve().parent.parent + vcs_src = f"git+file://{template_repo}" cmd: list[str] = [ "copier", "copy", "--vcs-ref", "HEAD", - ".", + vcs_src, str(dest), "--trust", "--defaults", @@ -262,13 +264,15 @@ def test_generate_defaults_only_cli(tmp_path: Path) -> None: Pass explicit ``--data`` when you need a different distribution name. """ test_dir = tmp_path / "defaults_only" + template_repo = Path(__file__).resolve().parent.parent + vcs_src = f"git+file://{template_repo}" _ = run_command( [ "copier", "copy", "--vcs-ref", "HEAD", - ".", + vcs_src, str(test_dir), "--trust", "--defaults", @@ -307,13 +311,15 @@ def test_copier_yaml_has_no_codecov_token_prompt() -> None: def test_package_name_validator_rejects_leading_digit(tmp_path: Path) -> None: """Digit-leading ``package_name`` values must fail Copier validation.""" test_dir = tmp_path / "bad_pkg" + template_repo = Path(__file__).resolve().parent.parent + vcs_src = f"git+file://{template_repo}" proc = run_command( [ "copier", "copy", "--vcs-ref", "HEAD", - ".", + vcs_src, str(test_dir), "--trust", "--defaults", @@ -329,7 +335,19 @@ def test_package_name_validator_rejects_leading_digit(tmp_path: Path) -> None: def test_computed_values_not_recorded_in_answers_file(tmp_path: Path) -> None: """Questions with ``when: false`` must not be stored in the answers file.""" test_dir = tmp_path / "computed_answers" - _ = run_command(["copier", "copy", ".", str(test_dir), "--trust", "--defaults", "--skip-tasks"]) + template_repo = Path(__file__).resolve().parent.parent + vcs_src = f"git+file://{template_repo}" + _ = run_command( + [ + "copier", + "copy", + vcs_src, + str(test_dir), + "--trust", + "--defaults", + "--skip-tasks", + ] + ) _remove_empty_optional_artifacts( test_dir, { @@ -346,7 +364,10 @@ def test_computed_values_not_recorded_in_answers_file(tmp_path: Path) -> None: def test_answers_file_warns_never_edit_manually(tmp_path: Path) -> None: """Generated answers file should match Copier docs banner text.""" test_dir = tmp_path / "answers_banner" - _ = run_command(["copier", "copy", ".", str(test_dir), "--trust", "--defaults", "--skip-tasks"]) + vcs_src = f"git+file://{Path('.').resolve()}" + _ = run_command( + ["copier", "copy", vcs_src, str(test_dir), "--trust", "--defaults", "--skip-tasks"] + ) _remove_empty_optional_artifacts( test_dir, { @@ -362,8 +383,11 @@ def test_answers_file_warns_never_edit_manually(tmp_path: Path) -> None: def test_generate_programmatic_run_copy_local(tmp_path: Path) -> None: """Render programmatically with :func:`copier.run_copy` from a local path.""" test_dir = tmp_path / "programmatic_local" + # Use a VCS-style local source to avoid git hardlink issues that can occur with + # local clones in some container/filesystem setups. + vcs_src = f"git+file://{Path('.').resolve()}" _worker = run_copy( - ".", + vcs_src, test_dir, defaults=True, unsafe=True,