← Back to Gists

Project Tracker

📝 Python
retoor
retoor · Level 1831 ·

This script tracks what projects you are working on, even with large 50GB code bases - it is already used and especially made for that. Perfect for people working concurrent on many.

It does not need any dependencies and is a single file, which I prefer for tools.

Python
#!/usr/bin/env python3
# retoor <retoor@molodetz.nl>

from __future__ import annotations

import argparse
import asyncio
import logging
import os
import sys
import time
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta
from itertools import zip_longest
from pathlib import Path
from typing import Iterator

SCAN_TIMEOUT_SECONDS_DEFAULT = 5.0
DISCOVERY_TIMEOUT_SECONDS_DEFAULT = 5.0
MAX_FILES_PER_PROJECT_DEFAULT = 4000
MAX_WORKERS_DEFAULT = min(32, max(4, (os.cpu_count() or 4) * 4))
DAYS_SHOWN_DEFAULT = 10
ACTIVE_WINDOW_DAYS_DEFAULT = 5

ANSI_RESET = "\x1b[0m"
ANSI_BOLD = "\x1b[1m"
ANSI_DIM = "\x1b[2m"
ANSI_RED = "\x1b[31m"
ANSI_GREEN = "\x1b[32m"
ANSI_GREEN_BRIGHT = "\x1b[92m"
ANSI_YELLOW = "\x1b[33m"
ANSI_CYAN = "\x1b[36m"

LEVEL_COLORS = {
    logging.DEBUG: ANSI_DIM,
    logging.INFO: ANSI_CYAN,
    logging.WARNING: ANSI_YELLOW,
    logging.ERROR: ANSI_RED,
}

ALLOWED_DOT_DIRECTORIES = frozenset({".github", ".gitea", ".circleci"})

EXCLUDED_DIRECTORY_NAMES = frozenset({
    "node_modules", "venv", "env", "site-packages", "dist", "build",
    "target", "out", "obj", "bin", "vendor", "bower_components",
    "coverage", "htmlcov", "__pycache__", "pods", "deriveddata",
    "cmakefiles", "_build", "deps", "nimcache", "carthage", "elm-stuff",
    "_site",
})

EXCLUDED_DIRECTORY_PREFIXES = frozenset({"cmake-build-", "bazel-"})

EXCLUDED_DIRECTORY_SUFFIXES = frozenset({".egg-info", ".xcodeproj", ".xcworkspace"})

EXCLUDED_FILENAMES = frozenset({
    "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "poetry.lock",
    "cargo.lock", "composer.lock", "gemfile.lock", "go.sum",
    "mix.lock", "flake.lock", "package.resolved", "pipfile.lock",
})

SPECIAL_SOURCE_FILENAMES = frozenset({
    "makefile", "dockerfile", "jenkinsfile", "gemfile", "rakefile",
    "procfile", "vagrantfile", "cmakelists.txt", "podfile", "fastfile",
    "appfile", "build", "build.bazel", "workspace", "brewfile",
})

PROJECT_MARKER_FILENAMES = frozenset({
    "pyproject.toml", "setup.py", "setup.cfg", "package.json",
    "cargo.toml", "go.mod", "go.work", "composer.json", "gemfile",
    "pom.xml", "build.gradle", "build.gradle.kts", "cmakelists.txt",
    "makefile", "mix.exs", "package.swift", "stack.yaml",
    "dune-project", "rebar.config", "workspace", "build.bazel",
    "pubspec.yaml", "project.clj", "deps.edn", "shard.yml",
})

PROJECT_MARKER_SUFFIXES = frozenset({
    ".nimble", ".csproj", ".sln", ".fsproj", ".vbproj",
})

PROJECT_MARKER_DIRECTORY_SUFFIXES = frozenset({".xcodeproj", ".xcworkspace"})

INCLUDED_SOURCE_SUFFIXES = frozenset({
    ".py", ".pyi", ".pyx",
    ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".vue", ".svelte", ".astro",
    ".java", ".kt", ".kts", ".scala", ".sc", ".groovy", ".gvy",
    ".clj", ".cljs", ".cljc", ".edn",
    ".c", ".h", ".cpp", ".cc", ".cxx", ".c++", ".hpp", ".hh", ".hxx",
    ".h++", ".ipp", ".tpp", ".inl",
    ".cs", ".vb", ".fs", ".fsi", ".fsx", ".csproj", ".fsproj", ".vbproj",
    ".sln", ".razor", ".cshtml", ".xaml",
    ".go", ".mod",
    ".rs",
    ".rb", ".gemspec", ".ru",
    ".php", ".phtml",
    ".html", ".htm", ".css", ".scss", ".sass", ".less",
    ".sh", ".bash", ".zsh", ".fish", ".ps1", ".psm1", ".bat", ".cmd",
    ".sql",
    ".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".xml", ".conf",
    ".tf", ".tfvars", ".hcl",
    ".md", ".rst", ".txt",
    ".swift", ".m", ".mm",
    ".lua", ".pl", ".pm", ".t",
    ".r", ".rmd",
    ".ex", ".exs", ".eex", ".heex", ".leex", ".erl", ".hrl",
    ".hs", ".lhs",
    ".ml", ".mli", ".re", ".rei", ".res", ".resi",
    ".dart", ".jl", ".zig", ".cr", ".d", ".v",
    ".f90", ".f95", ".f03", ".for", ".f",
    ".adb", ".ads",
    ".cob", ".cbl",
    ".pas", ".pp", ".dpr",
    ".asm", ".s",
    ".proto", ".graphql", ".gql",
    ".wat", ".wast",
    ".sol", ".nix", ".elm",
    ".nim", ".nims", ".nimble",
})

LOGGER = logging.getLogger("indevelopment")


@dataclass(frozen=True)
class ScanConfig:
    root: Path
    timeout_seconds: float
    discovery_timeout_seconds: float
    max_files_per_project: int
    max_workers: int
    days_shown: int
    active_window_days: int
    verbose: bool
    use_color: bool


@dataclass
class ProjectScanResult:
    name: str
    path: Path
    file_mtimes: list[float] = field(default_factory=list)
    files_examined: int = 0
    directories_visited: int = 0
    directories_skipped: int = 0
    timed_out: bool = False
    capped: bool = False
    elapsed_seconds: float = 0.0

    @property
    def last_activity(self) -> datetime | None:
        if not self.file_mtimes:
            return None
        return datetime.fromtimestamp(max(self.file_mtimes))

    @property
    def activity_by_date(self) -> dict[date, int]:
        counter: Counter[date] = Counter()
        for mtime in self.file_mtimes:
            counter[datetime.fromtimestamp(mtime).date()] += 1
        return dict(sorted(counter.items()))


@dataclass
class ProjectNode:
    path: Path
    name: str
    relative_label: str
    reason: str
    depth: int
    children: list[ProjectNode] = field(default_factory=list)
    scan: ProjectScanResult | None = None

    def sort_key(self) -> datetime:
        if self.scan is not None and self.scan.last_activity is not None:
            return self.scan.last_activity
        return datetime.min


class ElapsedFormatter(logging.Formatter):
    def __init__(self, start_time: float, use_color: bool) -> None:
        super().__init__()
        self.start_time = start_time
        self.use_color = use_color

    def format(self, record: logging.LogRecord) -> str:
        elapsed = time.monotonic() - self.start_time
        level_color = LEVEL_COLORS.get(record.levelno, "") if self.use_color else ""
        reset = ANSI_RESET if self.use_color else ""
        dim = ANSI_DIM if self.use_color else ""
        return f"{dim}[{elapsed:7.3f}s]{reset} {level_color}{record.levelname:<7}{reset} {record.getMessage()}"


def is_excluded_directory(name: str) -> bool:
    if name in ALLOWED_DOT_DIRECTORIES:
        return False
    if name.startswith("."):
        return True
    lowered = name.lower()
    if any(lowered.endswith(suffix) for suffix in EXCLUDED_DIRECTORY_SUFFIXES):
        return True
    if any(lowered.startswith(prefix) for prefix in EXCLUDED_DIRECTORY_PREFIXES):
        return True
    return name in EXCLUDED_DIRECTORY_NAMES or lowered in EXCLUDED_DIRECTORY_NAMES


def is_source_file(filename: str) -> bool:
    lowered = filename.lower()
    if lowered.startswith(".env"):
        return False
    if lowered in EXCLUDED_FILENAMES:
        return False
    if lowered in SPECIAL_SOURCE_FILENAMES:
        return True
    return Path(filename).suffix.lower() in INCLUDED_SOURCE_SUFFIXES


def round_robin(batches: list[list[os.DirEntry]]) -> Iterator[os.DirEntry]:
    for group in zip_longest(*batches, fillvalue=None):
        for entry in group:
            if entry is not None:
                yield entry


def inspect_directory(path: Path) -> tuple[str | None, list[Path]]:
    try:
        entries = list(os.scandir(path))
    except OSError as error:
        LOGGER.debug(f"discovery skip unreadable dir={path} reason={error!r}")
        return None, []

    marker_reason: str | None = None
    filenames_lower: set[str] = set()
    subdirs: list[Path] = []

    for entry in entries:
        try:
            if entry.is_symlink():
                continue
            if entry.is_dir(follow_symlinks=False):
                if entry.name == ".git":
                    marker_reason = "git repository (.git present)"
                    continue
                lowered_dir_name = entry.name.lower()
                if marker_reason is None and any(
                    lowered_dir_name.endswith(suffix) for suffix in PROJECT_MARKER_DIRECTORY_SUFFIXES
                ):
                    marker_reason = f"project bundle present ({entry.name})"
                if is_excluded_directory(entry.name):
                    continue
                subdirs.append(Path(entry.path))
            elif entry.is_file(follow_symlinks=False):
                filenames_lower.add(entry.name.lower())
        except OSError as error:
            LOGGER.debug(f"discovery skip entry={entry.path} reason={error!r}")

    if marker_reason is None:
        for filename in PROJECT_MARKER_FILENAMES:
            if filename in filenames_lower:
                marker_reason = f"manifest file present ({filename})"
                break

    if marker_reason is None:
        for filename in filenames_lower:
            if Path(filename).suffix.lower() in PROJECT_MARKER_SUFFIXES:
                marker_reason = f"manifest file present ({filename})"
                break

    return marker_reason, subdirs


def collect_nested_projects(directory: Path, deadline: float, parent_path: Path, depth: int) -> list[ProjectNode]:
    if time.monotonic() > deadline:
        LOGGER.warning(f"discovery deadline reached before descending into {directory}, nested search stopped early here")
        return []

    _, subdirs = inspect_directory(directory)
    nodes: list[ProjectNode] = []

    for sub in subdirs:
        if time.monotonic() > deadline:
            LOGGER.warning(f"discovery deadline reached at {sub}, nested search stopped early here")
            break

        sub_marker_reason, _ = inspect_directory(sub)
        if sub_marker_reason is not None:
            relative_label = str(sub.relative_to(parent_path))
            LOGGER.info(f"nested project discovered: {relative_label} (inside {parent_path.name}) reason={sub_marker_reason}")
            node = ProjectNode(
                path=sub, name=sub.name, relative_label=relative_label,
                reason=sub_marker_reason, depth=depth,
            )
            node.children = collect_nested_projects(sub, deadline, sub, depth + 1)
            nodes.append(node)
        else:
            nodes.extend(collect_nested_projects(sub, deadline, parent_path, depth))

    return nodes


def build_project_node(path: Path, config: ScanConfig) -> ProjectNode:
    started = time.monotonic()
    deadline = started + config.discovery_timeout_seconds
    LOGGER.info(f"[{path.name}] nested-project discovery started deadline={config.discovery_timeout_seconds:.1f}s")

    marker_reason, _ = inspect_directory(path)
    reason = marker_reason if marker_reason is not None else "top-level directory under scan root"
    node = ProjectNode(path=path, name=path.name, relative_label=path.name, reason=reason, depth=0)
    node.children = collect_nested_projects(path, deadline, path, 1)

    elapsed = time.monotonic() - started
    LOGGER.info(f"[{path.name}] nested-project discovery finished elapsed={elapsed:.3f}s nested-found={len(flatten_tree(node.children))}")
    return node


def flatten_tree(nodes: list[ProjectNode]) -> list[ProjectNode]:
    flat: list[ProjectNode] = []
    for node in nodes:
        flat.append(node)
        flat.extend(flatten_tree(node.children))
    return flat


def print_criteria_banner(config: ScanConfig) -> None:
    LOGGER.info(f"root directory: {config.root}")
    LOGGER.info(
        "criteria: every immediate subdirectory of root is treated as a top-level project regardless "
        "of markers, so ad-hoc working directories are never missed; projects nested inside those "
        "top-level directories are recognized separately, see below"
    )
    LOGGER.info(
        "criteria: a directory is skipped when hidden (leading dot) unless allow-listed "
        f"({', '.join(sorted(ALLOWED_DOT_DIRECTORIES))}), or when its name matches a known "
        f"tooling/dependency directory ({', '.join(sorted(EXCLUDED_DIRECTORY_NAMES))}) because "
        "such content is generated or vendored, never authored by hand"
    )
    LOGGER.info(
        f"criteria: a file is counted only when its extension is one of {len(INCLUDED_SOURCE_SUFFIXES)} "
        "known source/config extensions, deliberately broad across ecosystems: Python (.py .pyi .pyx), "
        "JS/TS/Node (.js .ts .tsx .jsx .vue .svelte), C/C++ (.c .h .cpp .hpp .cxx), Rust (.rs), "
        "Swift/ObjC (.swift .m .mm), Ruby (.rb .gemspec), Java/JVM (.java .kt .scala .groovy .clj), "
        "C#/.NET (.cs .fs .vb .csproj), Go (.go .mod), Nim (.nim .nims .nimble), PHP, Lua, Perl, R, "
        "Elixir/Erlang, Haskell, OCaml, Dart, Zig, Crystal, D, Fortran, Ada, Pascal, plus web/markup/"
        "config formats (.html .css .json .yaml .toml .md ...); or its filename matches a known build "
        "file (Makefile, Dockerfile, CMakeLists.txt, BUILD.bazel, Podfile, ...); .env* files and "
        f"generated lock/manifest files ({', '.join(sorted(EXCLUDED_FILENAMES))}) are always skipped"
    )
    LOGGER.info(
        f"criteria: each project gets its own {config.timeout_seconds:.1f}s soft deadline, checked "
        "cooperatively between directories so one slow project cannot delay the others; all "
        f"projects are scanned concurrently across up to {config.max_workers} worker threads"
    )
    LOGGER.info(
        "criteria: traversal is breadth-first, level by level; within a level, files are sampled "
        "round-robin across every sibling directory instead of directory-by-directory, so the "
        f"per-project cap of {config.max_files_per_project} files is spread evenly instead of being "
        "exhausted by the first deep branch found"
    )
    LOGGER.info(
        "criteria: recency is derived from filesystem mtime; a project's rank is its single most "
        "recent file mtime, results are printed least-recent first, most-recent last"
    )
    LOGGER.info(
        f"criteria: a project is classified as 'currently in development' when its most recent "
        f"file mtime is no more than {config.active_window_days} day(s) old; that classification is "
        "reported separately at the end, ranked oldest to newest so the most active project is last"
    )
    LOGGER.info(
        "criteria: every top-level project is additionally searched recursively for nested project "
        "roots - any subdirectory at any depth that contains its own .git (a git repository), or a "
        "recognized manifest file (pyproject.toml, package.json, Cargo.toml, go.mod, Makefile, "
        "CMakeLists.txt, pom.xml, build.gradle, mix.exs, Package.swift, *.nimble, *.csproj, "
        "*.xcodeproj, ...), is registered as a project of its own, nested arbitrarily deep (a "
        "project inside a project inside a project); intermediate non-project directories are "
        "skipped over but their path is kept in the nested project's label, e.g. 'libs/nimcheck'"
    )
    LOGGER.info(
        f"criteria: nested-project discovery runs within its own {config.discovery_timeout_seconds:.1f}s "
        "soft budget per top-level project, and only lists directories and manifest filenames, it "
        "never reads file contents so it stays fast even on very large trees; once a nested project "
        "is found, the file-activity scan of every ancestor project stops descending into it, so "
        "activity statistics are never double-counted between a project and the ones nested inside it"
    )


def discover_project_directories(config: ScanConfig) -> list[Path]:
    directories: list[Path] = []
    try:
        entries = sorted(config.root.iterdir(), key=lambda entry: entry.name.lower())
    except OSError as error:
        LOGGER.error(f"cannot list root directory {config.root}: {error!r}")
        return directories
    for entry in entries:
        if not entry.is_dir(follow_symlinks=False):
            continue
        if is_excluded_directory(entry.name):
            LOGGER.debug(f"root-scan skip dir={entry.name} reason=excluded-by-criteria")
            continue
        directories.append(entry)
        LOGGER.info(f"root-scan candidate project discovered: {entry.name}")
    return directories


def scan_project(node: ProjectNode, config: ScanConfig) -> ProjectScanResult:
    path = node.path
    name = str(path.relative_to(config.root))
    boundary_paths = {child.path for child in node.children}
    started = time.monotonic()
    deadline = started + config.timeout_seconds
    LOGGER.info(
        f"[{name}] scan started root={path} deadline={config.timeout_seconds:.1f}s "
        f"cap={config.max_files_per_project} files nested-boundaries={len(boundary_paths)}"
    )
    result = ProjectScanResult(name=name, path=path)
    current_level: list[Path] = [path]
    depth = 0

    while current_level and result.files_examined < config.max_files_per_project:
        if time.monotonic() > deadline:
            result.timed_out = True
            LOGGER.warning(f"[{name}] deadline reached at depth={depth}, reporting partial results")
            break

        next_level: list[Path] = []
        level_batches: list[list[os.DirEntry]] = []

        for directory in current_level:
            if time.monotonic() > deadline:
                result.timed_out = True
                break
            result.directories_visited += 1
            try:
                entries = list(os.scandir(directory))
            except OSError as error:
                LOGGER.debug(f"[{name}] skip unreadable dir={directory} reason={error!r}")
                continue

            file_batch: list[os.DirEntry] = []
            for entry in entries:
                try:
                    if entry.is_symlink():
                        continue
                    if entry.is_dir(follow_symlinks=False):
                        entry_path = Path(entry.path)
                        if entry_path in boundary_paths:
                            result.directories_skipped += 1
                            LOGGER.info(f"[{name}] skip dir={entry.path} reason=nested-project-boundary (scanned independently)")
                            continue
                        if is_excluded_directory(entry.name):
                            result.directories_skipped += 1
                            if config.verbose:
                                LOGGER.debug(f"[{name}] skip dir={entry.path} reason=excluded-by-criteria")
                            continue
                        next_level.append(entry_path)
                    elif entry.is_file(follow_symlinks=False):
                        if is_source_file(entry.name):
                            file_batch.append(entry)
                        elif config.verbose:
                            LOGGER.debug(f"[{name}] skip file={entry.path} reason=not-source-extension")
                except OSError as error:
                    LOGGER.debug(f"[{name}] skip entry={entry.path} reason={error!r}")

            if file_batch:
                level_batches.append(file_batch)

        if result.timed_out:
            break

        if level_batches:
            found = sum(len(batch) for batch in level_batches)
            LOGGER.info(
                f"[{name}] depth={depth} visited-dirs-this-level={len(current_level)} "
                f"source-files-found={found}"
            )

        for entry in round_robin(level_batches):
            if result.files_examined >= config.max_files_per_project:
                result.capped = True
                LOGGER.warning(f"[{name}] per-project file cap reached at depth={depth}, reporting partial results")
                break
            if time.monotonic() > deadline:
                result.timed_out = True
                LOGGER.warning(f"[{name}] deadline reached mid-level at depth={depth}, reporting partial results")
                break
            try:
                result.file_mtimes.append(entry.stat(follow_symlinks=False).st_mtime)
                result.files_examined += 1
            except OSError as error:
                LOGGER.debug(f"[{name}] skip file={entry.path} reason={error!r}")

        if result.timed_out or result.capped:
            break

        current_level = next_level
        depth += 1

    result.elapsed_seconds = time.monotonic() - started
    LOGGER.info(
        f"[{name}] scan finished elapsed={result.elapsed_seconds:.3f}s files={result.files_examined} "
        f"dirs-visited={result.directories_visited} dirs-skipped={result.directories_skipped} "
        f"timed-out={result.timed_out} capped={result.capped}"
    )
    return result


def colorize(config: ScanConfig, code: str, text: str) -> str:
    return f"{code}{text}{ANSI_RESET}" if config.use_color else text


def activity_color(age_days: int) -> str:
    if age_days <= 1:
        return ANSI_GREEN_BRIGHT
    if age_days <= 7:
        return ANSI_GREEN
    if age_days <= 30:
        return ANSI_YELLOW
    return ANSI_RED


def render_report(results: list[ProjectScanResult], config: ScanConfig) -> None:
    ordered = sorted(results, key=lambda result: result.last_activity or datetime.min)
    now = datetime.now()

    print()
    print(colorize(config, ANSI_BOLD, "RECENTLY WORKED ON PROJECTS") + "  (oldest first, most recent last)")
    print(colorize(config, ANSI_DIM, "-" * 78))

    for result in ordered:
        last = result.last_activity
        if last is None:
            header_color = ANSI_DIM
            age_label = colorize(config, ANSI_DIM, "no source files matched criteria")
        else:
            age_days = (now - last).days
            header_color = activity_color(age_days)
            age_label = f"last activity {last:%Y-%m-%d %H:%M}  ({age_days}d ago)"

        flags = []
        if result.timed_out:
            flags.append("TIMEOUT-partial")
        if result.capped:
            flags.append("CAP-reached")
        flags_label = f"  [{', '.join(flags)}]" if flags else ""

        print(f"\n{colorize(config, header_color, f'{result.name:<34}')} {age_label}{flags_label}")
        print(colorize(
            config, ANSI_DIM,
            f"  path={result.path}  files-examined={result.files_examined} "
            f"dirs-visited={result.directories_visited} dirs-skipped={result.directories_skipped} "
            f"scan-time={result.elapsed_seconds:.2f}s",
        ))

        by_date = list(result.activity_by_date.items())
        if by_date:
            shown = by_date[-config.days_shown:]
            omitted = len(by_date) - len(shown)
            if omitted > 0:
                print(colorize(config, ANSI_DIM, f"  ... {omitted} earlier active day(s) omitted ..."))
            for day, count in shown:
                bar = "#" * min(count, 40)
                print(f"    {day}  {count:>5} file(s)  {colorize(config, ANSI_DIM, bar)}")

    print()
    print(colorize(config, ANSI_DIM, "-" * 78))
    total_files = sum(result.files_examined for result in ordered)
    total_partial = sum(1 for result in ordered if result.timed_out or result.capped)
    print(
        f"projects={len(ordered)}  files-examined-total={total_files}  "
        f"partial-results={total_partial}"
    )
    print()


def describe_active_reason(result: ProjectScanResult, config: ScanConfig, now: datetime) -> str:
    last = result.last_activity
    assert last is not None
    age_days = (now - last).days
    window_start = (now - timedelta(days=config.active_window_days)).date()
    by_date = result.activity_by_date
    recent_days = sorted(day for day in by_date if day >= window_start)
    recent_files = sum(by_date[day] for day in recent_days)
    last_day_files = by_date.get(last.date(), 0)

    parts = [f"most recent edit was {age_days}d ago ({last.date()})"]
    parts.append(f"{last_day_files} file(s) touched that day")
    if len(recent_days) > 1:
        parts.append(
            f"active on {len(recent_days)} of the last {config.active_window_days} day(s), "
            f"{recent_files} file(s) touched in that window"
        )
    else:
        parts.append(f"only a single active day within the last {config.active_window_days} day(s)")
    if result.capped:
        parts.append("file cap reached during scan, so true recent volume is at least this high")
    if result.timed_out:
        parts.append("scan deadline reached, so true recent volume is at least this high")
    return "; ".join(parts)


def render_active_summary(results: list[ProjectScanResult], config: ScanConfig) -> None:
    now = datetime.now()
    active = [
        result for result in results
        if result.last_activity is not None
        and (now - result.last_activity).days <= config.active_window_days
    ]
    active.sort(key=lambda result: result.last_activity)

    print(colorize(
        config, ANSI_BOLD,
        f"CURRENTLY IN ACTIVE DEVELOPMENT  (most recent edit <= {config.active_window_days}d ago, "
        "oldest first, most active last)",
    ))
    print(colorize(config, ANSI_DIM, "-" * 78))

    if not active:
        print(colorize(
            config, ANSI_DIM,
            f"no project shows a source file edited within the last {config.active_window_days} day(s)",
        ))
        print()
        return

    for result in active:
        last = result.last_activity
        assert last is not None
        age_days = (now - last).days
        header_color = activity_color(age_days)
        print(f"\n{colorize(config, header_color, f'{result.name:<34}')} {last:%Y-%m-%d %H:%M}  ({age_days}d ago)")
        print(colorize(config, ANSI_DIM, f"  reason: {describe_active_reason(result, config, now)}"))

    print()


def render_project_tree(top_level_nodes: list[ProjectNode], config: ScanConfig) -> None:
    now = datetime.now()

    def fact_line(node: ProjectNode) -> str:
        scan = node.scan
        if scan is None:
            return colorize(config, ANSI_DIM, "not scanned")
        last = scan.last_activity
        if last is None:
            return colorize(config, ANSI_DIM, "no source files matched criteria")
        age_days = (now - last).days
        color = activity_color(age_days)
        flags = []
        if scan.timed_out:
            flags.append("TIMEOUT")
        if scan.capped:
            flags.append("CAPPED")
        flags_label = f" [{','.join(flags)}]" if flags else ""
        return colorize(
            config, color,
            f"last={last:%Y-%m-%d} ({age_days}d ago)  files={scan.files_examined}{flags_label}",
        )

    def render(node: ProjectNode, prefix: str, is_last: bool, is_root: bool) -> None:
        connector = "" if is_root else ("โ””โ”€โ”€ " if is_last else "โ”œโ”€โ”€ ")
        reason_label = colorize(config, ANSI_DIM, f"[{node.reason}]")
        print(f"{prefix}{connector}{node.relative_label}  {reason_label}  {fact_line(node)}")
        child_prefix = prefix if is_root else prefix + ("    " if is_last else "โ”‚   ")
        ordered_children = sorted(node.children, key=lambda child: child.sort_key())
        for index, child in enumerate(ordered_children):
            render(child, child_prefix, index == len(ordered_children) - 1, False)

    print(colorize(
        config, ANSI_BOLD,
        "PROJECT TREE  (including projects nested inside other projects, oldest first per branch)",
    ))
    print(colorize(config, ANSI_DIM, "-" * 78))

    ordered_roots = sorted(top_level_nodes, key=lambda node: node.sort_key())
    for index, node in enumerate(ordered_roots):
        render(node, "", index == len(ordered_roots) - 1, True)

    print()


async def run_scan(config: ScanConfig) -> int:
    if not config.root.is_dir():
        LOGGER.error(f"root is not a directory: {config.root}")
        return 1

    print_criteria_banner(config)
    project_dirs = discover_project_directories(config)
    if not project_dirs:
        LOGGER.warning(f"no project directories found under {config.root}")
        return 1

    LOGGER.info(
        f"discovered {len(project_dirs)} top-level project candidate(s); now searching each one "
        f"recursively for nested project roots, concurrently, up to {config.max_workers} worker threads"
    )

    loop = asyncio.get_running_loop()
    with ThreadPoolExecutor(max_workers=config.max_workers) as executor:
        tree_tasks = [
            loop.run_in_executor(executor, build_project_node, project_dir, config)
            for project_dir in project_dirs
        ]
        top_level_nodes = list(await asyncio.gather(*tree_tasks))

        flat_nodes = flatten_tree(top_level_nodes)
        nested_count = len(flat_nodes) - len(top_level_nodes)
        LOGGER.info(
            f"discovery finished: {len(top_level_nodes)} top-level project(s), {nested_count} nested "
            f"project(s) recognized, {len(flat_nodes)} project(s) total will be scanned for activity"
        )

        scan_tasks = [
            loop.run_in_executor(executor, scan_project, node, config)
            for node in flat_nodes
        ]
        scan_results = await asyncio.gather(*scan_tasks)

    for node, result in zip(flat_nodes, scan_results):
        node.scan = result

    render_project_tree(top_level_nodes, config)
    render_report(list(scan_results), config)
    render_active_summary(list(scan_results), config)
    return 0


def build_arg_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Determine which project directories under a root were worked on most recently.",
    )
    parser.add_argument(
        "root", nargs="?", default=".", type=Path,
        help="root directory containing project subdirectories (default: current directory)",
    )
    parser.add_argument(
        "--timeout", type=float, default=SCAN_TIMEOUT_SECONDS_DEFAULT,
        help="soft per-project scan deadline in seconds (default: %(default)s)",
    )
    parser.add_argument(
        "--max-files", type=int, default=MAX_FILES_PER_PROJECT_DEFAULT,
        help="maximum source files examined per project (default: %(default)s)",
    )
    parser.add_argument(
        "--workers", type=int, default=MAX_WORKERS_DEFAULT,
        help="maximum concurrent project scan threads (default: %(default)s)",
    )
    parser.add_argument(
        "--discovery-timeout", type=float, default=DISCOVERY_TIMEOUT_SECONDS_DEFAULT,
        help=(
            "soft per-top-level-project deadline in seconds for recursive nested-project "
            "discovery (default: %(default)s)"
        ),
    )
    parser.add_argument(
        "--days", type=int, default=DAYS_SHOWN_DEFAULT,
        help="maximum number of active days shown per project (default: %(default)s)",
    )
    parser.add_argument(
        "--active-days", type=int, default=ACTIVE_WINDOW_DAYS_DEFAULT,
        help=(
            "maximum age in days for a project to be classified as currently in "
            "active development (default: %(default)s)"
        ),
    )
    parser.add_argument(
        "--verbose", action="store_true",
        help="also log every skipped file, not just skipped directories",
    )
    parser.add_argument(
        "--no-color", action="store_true",
        help="disable ANSI colors",
    )
    return parser


def main() -> int:
    args = build_arg_parser().parse_args()
    use_color = not args.no_color and sys.stdout.isatty()
    start_time = time.monotonic()

    LOGGER.setLevel(logging.DEBUG if args.verbose else logging.INFO)
    handler = logging.StreamHandler(sys.stdout)
    handler.setFormatter(ElapsedFormatter(start_time, use_color))
    LOGGER.handlers.clear()
    LOGGER.addHandler(handler)
    LOGGER.propagate = False

    config = ScanConfig(
        root=Path(args.root).resolve(),
        timeout_seconds=args.timeout,
        discovery_timeout_seconds=args.discovery_timeout,
        max_files_per_project=args.max_files,
        max_workers=args.workers,
        days_shown=args.days,
        active_window_days=args.active_days,
        verbose=args.verbose,
        use_color=use_color,
    )

    try:
        return asyncio.run(run_scan(config))
    except KeyboardInterrupt:
        LOGGER.warning("interrupted by user")
        return 130


if __name__ == "__main__":
    sys.exit(main())

Comments

0
retoor retoor

I will make it give a more detailed than 0d ago - it will be 34 minutes ago and stuff ๐Ÿ˜„