Project Tracker
📝 PythonThis 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
I will make it give a more detailed than 0d ago - it will be 34 minutes ago and stuff ๐