Skip to content

API Reference

Recursivist is organized as a set of focused modules that can be used directly from Python. This page documents the public API (generated from the source docstrings) and shows how to compose it.

Module Overview

Module Responsibility
recursivist.scanner Walk a directory into the nested structure dict
recursivist.tree Render a structure as a Rich tree in the terminal
recursivist.exporters Exporter registry and per-format exporters
recursivist.compare Compare and render two structures
recursivist.filtering Ignore-file, glob, and regex exclusion logic
recursivist.flags Resolve sort/display flags into a DisplayOptions
recursivist.sorting File ordering (by type, metric, or name similarity)
recursivist.metrics Lines of code, size, mtime, and metric formatting
recursivist.colors Deterministic per-extension colors
recursivist.icons Emoji and Nerd Font icon lookup
recursivist.git_status Git status lookup
recursivist.github Materialize a GitHub repository for scanning
recursivist.config User-configuration persistence

The Structure Dictionary

Most of the API revolves around the nested dictionary produced by get_directory_structure. Each subdirectory is a nested dict under its own name; a directory's own files and aggregate metrics live under reserved keys:

  • _files: a list of FileEntry objects for the directory's files
  • _loc, _size, _mtime: aggregate totals, present only when the matching metric is requested
  • _max_depth_reached: present when traversal stopped at the depth limit
  • _hidden_contents: present alongside _max_depth_reached when the untraversed directory is not empty, so renderers can tell it apart from one that holds nothing
  • _git_markers: a {filename: status} map, present only with Git status enabled

DisplayOptions

Sorting and annotation are driven by a single resolved value, recursivist.flags.DisplayOptions, which the renderers and exporters consult. It separates ordering (sort_key) from annotation (metrics and show_git_status):

from recursivist.flags import DisplayOptions

# Sort by lines of code; annotate each file with LOC then size
spec = DisplayOptions(sort_key="loc", metrics=("loc", "size"))

sort_key is one of "loc", "size", "mtime", "git_status", "similarity", or None (the default extension/name order). metrics is the ordered tuple of numeric metrics to display, and show_git_status toggles the Git-status marker. To build a DisplayOptions from raw CLI flags — recovering their left-to-right order from argv — use recursivist.flags.resolve_display_options.

FileEntry

recursivist._models.FileEntry

Bases: NamedTuple

A single file within a scanned directory structure.

Attributes:

Name Type Description
name str

Bare filename (e.g. "main.py"). Used for icon lookup, extension detection and Git-status lookup.

path str

The string to display for this file — an absolute, forward-slash path when full-path display is enabled, otherwise just name.

loc int

Lines of code. Populated only when LOC counting is enabled during scanning; 0 otherwise.

size int

File size in bytes. Populated only when size tracking is enabled; 0 otherwise.

mtime float

Modification time (seconds since epoch). Populated only when mtime tracking is enabled; 0.0 otherwise.

Source code in recursivist/_models.py
class FileEntry(NamedTuple):
    """A single file within a scanned directory structure.

    Attributes:
        name: Bare filename (e.g. ``"main.py"``). Used for icon lookup,
            extension detection and Git-status lookup.
        path: The string to display for this file — an absolute, forward-slash
            path when full-path display is enabled, otherwise just ``name``.
        loc: Lines of code. Populated only when LOC counting is enabled during
            scanning; ``0`` otherwise.
        size: File size in bytes. Populated only when size tracking is enabled;
            ``0`` otherwise.
        mtime: Modification time (seconds since epoch). Populated only when
            mtime tracking is enabled; ``0.0`` otherwise.
    """

    name: str
    path: str
    loc: int = 0
    size: int = 0
    mtime: float = 0.0

    @classmethod
    def coerce(cls, item: Union["FileEntry", tuple[Any, ...], str]) -> "FileEntry":
        """Normalize any raw ``_files`` entry to a :class:`FileEntry` by position.

        A :class:`FileEntry` is returned unchanged; a bare string becomes a
        name-only entry; and a tuple is read by its canonical
        ``(name, path, loc, size, mtime)`` slots by index, defaulting any
        missing trailing field. This matches how the scanner always emits
        entries (as full :class:`FileEntry` values), and is the single
        normalization boundary the sorting layer routes file lists through
        before the metric values are read.

        Args:
            item: A :class:`FileEntry`, a positional tuple, or a bare filename
                string.

        Returns:
            The equivalent :class:`FileEntry`.
        """
        if isinstance(item, cls):
            return item
        if not isinstance(item, tuple):
            name = str(item)
            return cls(name=name, path=name)
        n = len(item)
        if n == 0:
            return cls(name="unknown", path="unknown")
        name = item[0]
        path = item[1] if n > 1 else name
        loc = item[2] if n > 2 else 0
        size = item[3] if n > 3 else 0
        mtime = item[4] if n > 4 else 0.0
        return cls(name=name, path=path, loc=loc, size=size, mtime=float(mtime))

coerce(item) classmethod

Normalize any raw _files entry to a :class:FileEntry by position.

A :class:FileEntry is returned unchanged; a bare string becomes a name-only entry; and a tuple is read by its canonical (name, path, loc, size, mtime) slots by index, defaulting any missing trailing field. This matches how the scanner always emits entries (as full :class:FileEntry values), and is the single normalization boundary the sorting layer routes file lists through before the metric values are read.

Parameters:

Name Type Description Default
item Union[FileEntry, tuple[Any, ...], str]

A :class:FileEntry, a positional tuple, or a bare filename string.

required

Returns:

Type Description
FileEntry

The equivalent :class:FileEntry.

Source code in recursivist/_models.py
@classmethod
def coerce(cls, item: Union["FileEntry", tuple[Any, ...], str]) -> "FileEntry":
    """Normalize any raw ``_files`` entry to a :class:`FileEntry` by position.

    A :class:`FileEntry` is returned unchanged; a bare string becomes a
    name-only entry; and a tuple is read by its canonical
    ``(name, path, loc, size, mtime)`` slots by index, defaulting any
    missing trailing field. This matches how the scanner always emits
    entries (as full :class:`FileEntry` values), and is the single
    normalization boundary the sorting layer routes file lists through
    before the metric values are read.

    Args:
        item: A :class:`FileEntry`, a positional tuple, or a bare filename
            string.

    Returns:
        The equivalent :class:`FileEntry`.
    """
    if isinstance(item, cls):
        return item
    if not isinstance(item, tuple):
        name = str(item)
        return cls(name=name, path=name)
    n = len(item)
    if n == 0:
        return cls(name="unknown", path="unknown")
    name = item[0]
    path = item[1] if n > 1 else name
    loc = item[2] if n > 2 else 0
    size = item[3] if n > 3 else 0
    mtime = item[4] if n > 4 else 0.0
    return cls(name=name, path=path, loc=loc, size=size, mtime=float(mtime))

Scanner

recursivist.scanner

Directory traversal.

Recursively walks a directory, applies the exclusion rules from :mod:recursivist.filtering, collects optional per-file metrics from :mod:recursivist.metrics, and returns the nested structure dict consumed by the renderers and exporters.

get_directory_structure(root_dir, exclude_dirs=None, ignore_file=None, exclude_extensions=None, parent_ignore_patterns=None, exclude_patterns=None, include_patterns=None, max_depth=0, current_depth=0, current_path='', show_full_path=False, sort_by_loc=False, sort_by_size=False, sort_by_mtime=False, show_git_status=False, git_status_map=None, ancestor_ids=None)

Build a nested dictionary representing a directory structure.

Recursively traverses root_dir, applying the exclusion rules and optionally collecting per-file metrics, and returns the nested mapping consumed by the renderers and exporters. Each subdirectory becomes a nested dict under its own name; a directory's files and aggregate metrics are stored under reserved keys.

Reserved keys in the returned structure:

  • "_files": list of :class:FileEntry for the directory's files.
  • "_loc": total lines of code (when sort_by_loc is set).
  • "_size": total size in bytes (when sort_by_size is set).
  • "_mtime": latest modification time (when sort_by_mtime is set).
  • "_max_depth_reached": present when traversal stopped at max_depth.
  • "_hidden_contents": present (and True) alongside "_max_depth_reached" when the untraversed directory is not empty, so renderers can still tell it apart from one that holds nothing.
  • "_symlink_loop": present (and True) when a directory was not recursed into because it resolves to one of its own ancestors, i.e. a symlink (or other) cycle back up the tree.
  • "_git_markers": {filename: status_char} (when show_git_status is set).

Parameters:

Name Type Description Default
root_dir str

Directory to scan.

required
exclude_dirs Sequence[str] | None

Directory names to skip entirely.

None
ignore_file str | None

Name of an ignore file to honor within each directory (e.g. .gitignore).

None
exclude_extensions set[str] | None

Lowercase, dot-prefixed extensions to exclude.

None
parent_ignore_patterns Sequence[tuple[str, tuple[str, ...]]] | None

Ignore files inherited from parent directories as a shallowest-first stack of (base_dir_relative_to_root, patterns) pairs. Each ignore file keeps its own anchoring so its patterns stay scoped to its subtree, matching Git. Set internally across the recursion.

None
exclude_patterns Sequence[str | Pattern[str]] | None

Glob or compiled-regex patterns to exclude.

None
include_patterns Sequence[str | Pattern[str]] | None

Glob or compiled-regex patterns to include, which override the exclusions.

None
max_depth int

Maximum depth to traverse, or 0 for unlimited.

0
current_depth int

Current recursion depth. Set internally.

0
current_path str

Path of the current directory relative to the scan root. Set internally.

''
show_full_path bool

Whether to store absolute paths instead of bare filenames.

False
sort_by_loc bool

Whether to count and total lines of code.

False
sort_by_size bool

Whether to measure and total file sizes.

False
sort_by_mtime bool

Whether to record file modification times.

False
show_git_status bool

Whether to annotate files with Git status markers.

False
git_status_map dict[str, str] | None

Pre-computed {rel_path: status_char} mapping, as returned by :func:recursivist.git_status.get_git_status.

None
ancestor_ids frozenset[tuple[int, int]] | None

(st_dev, st_ino) identities of the directories on the path from the scan root to (and including) root_dir, used to detect symlink cycles. Set internally across the recursion.

None

Returns:

Type Description
dict[str, Any]

A (structure, extensions) tuple, where structure is the nested

set[str]

directory mapping and extensions is the set of lowercase file

tuple[dict[str, Any], set[str]]

extensions encountered.

Source code in recursivist/scanner.py
def get_directory_structure(
    root_dir: str,
    exclude_dirs: Sequence[str] | None = None,
    ignore_file: str | None = None,
    exclude_extensions: set[str] | None = None,
    parent_ignore_patterns: Sequence[tuple[str, tuple[str, ...]]] | None = None,
    exclude_patterns: Sequence[str | Pattern[str]] | None = None,
    include_patterns: Sequence[str | Pattern[str]] | None = None,
    max_depth: int = 0,
    current_depth: int = 0,
    current_path: str = "",
    show_full_path: bool = False,
    sort_by_loc: bool = False,
    sort_by_size: bool = False,
    sort_by_mtime: bool = False,
    show_git_status: bool = False,
    git_status_map: dict[str, str] | None = None,
    ancestor_ids: frozenset[tuple[int, int]] | None = None,
) -> tuple[dict[str, Any], set[str]]:
    """Build a nested dictionary representing a directory structure.

    Recursively traverses *root_dir*, applying the exclusion rules and
    optionally collecting per-file metrics, and returns the nested mapping
    consumed by the renderers and exporters. Each subdirectory becomes a
    nested dict under its own name; a directory's files and aggregate metrics
    are stored under reserved keys.

    Reserved keys in the returned structure:

    - ``"_files"``: list of :class:`FileEntry` for the directory's files.
    - ``"_loc"``: total lines of code (when *sort_by_loc* is set).
    - ``"_size"``: total size in bytes (when *sort_by_size* is set).
    - ``"_mtime"``: latest modification time (when *sort_by_mtime* is set).
    - ``"_max_depth_reached"``: present when traversal stopped at *max_depth*.
    - ``"_hidden_contents"``: present (and ``True``) alongside
      ``"_max_depth_reached"`` when the untraversed directory is not empty, so
      renderers can still tell it apart from one that holds nothing.
    - ``"_symlink_loop"``: present (and ``True``) when a directory was not
      recursed into because it resolves to one of its own ancestors, i.e. a
      symlink (or other) cycle back up the tree.
    - ``"_git_markers"``: ``{filename: status_char}`` (when *show_git_status*
      is set).

    Args:
        root_dir: Directory to scan.
        exclude_dirs: Directory names to skip entirely.
        ignore_file: Name of an ignore file to honor within each directory
            (e.g. ``.gitignore``).
        exclude_extensions: Lowercase, dot-prefixed extensions to exclude.
        parent_ignore_patterns: Ignore files inherited from parent directories
            as a shallowest-first stack of ``(base_dir_relative_to_root,
            patterns)`` pairs. Each ignore file keeps its own anchoring so its
            patterns stay scoped to its subtree, matching Git. Set internally
            across the recursion.
        exclude_patterns: Glob or compiled-regex patterns to exclude.
        include_patterns: Glob or compiled-regex patterns to include, which
            override the exclusions.
        max_depth: Maximum depth to traverse, or ``0`` for unlimited.
        current_depth: Current recursion depth. Set internally.
        current_path: Path of the current directory relative to the scan
            root. Set internally.
        show_full_path: Whether to store absolute paths instead of bare
            filenames.
        sort_by_loc: Whether to count and total lines of code.
        sort_by_size: Whether to measure and total file sizes.
        sort_by_mtime: Whether to record file modification times.
        show_git_status: Whether to annotate files with Git status markers.
        git_status_map: Pre-computed ``{rel_path: status_char}`` mapping, as
            returned by :func:`recursivist.git_status.get_git_status`.
        ancestor_ids: ``(st_dev, st_ino)`` identities of the directories on the
            path from the scan root to (and including) *root_dir*, used to
            detect symlink cycles. Set internally across the recursion.

    Returns:
        A ``(structure, extensions)`` tuple, where *structure* is the nested
        directory mapping and *extensions* is the set of lowercase file
        extensions encountered.
    """
    if exclude_dirs is None:
        exclude_dirs = []
    if exclude_extensions is None:
        exclude_extensions = set()
    if exclude_patterns is None:
        exclude_patterns = []
    if include_patterns is None:
        include_patterns = []
    if ancestor_ids is None:
        ancestor_ids = frozenset()
    ignore_stack: list[tuple[str, tuple[str, ...]]] = (
        list(parent_ignore_patterns) if parent_ignore_patterns else []
    )
    if ignore_file:
        current_ignore_patterns = parse_ignore_file(os.path.join(root_dir, ignore_file))
        if current_ignore_patterns:
            ignore_stack = [
                *ignore_stack,
                (current_path, tuple(current_ignore_patterns)),
            ]
    ignore_context = {
        "pattern_stack": ignore_stack,
        "current_dir": root_dir,
        "rel_dir": current_path,
    }
    structure: dict[str, Any] = {}
    extensions_set: set[str] = set()
    total_loc = 0
    total_size = 0
    latest_mtime = 0.0

    git_markers: dict[str, str] = {}
    if show_git_status and git_status_map is not None:
        current_prefix = current_path.replace(os.sep, "/") if current_path else ""
        for git_path, status in git_status_map.items():
            slash_idx = git_path.rfind("/")
            if slash_idx == -1:
                file_dir, fname = "", git_path
            else:
                file_dir, fname = git_path[:slash_idx], git_path[slash_idx + 1 :]
            if file_dir == current_prefix:
                git_markers[fname] = status
    if max_depth > 0 and current_depth >= max_depth:
        truncated: dict[str, Any] = {"_max_depth_reached": True}
        if _has_visible_entries(
            root_dir,
            exclude_dirs,
            ignore_context,
            exclude_extensions,
            exclude_patterns,
            include_patterns,
        ):
            truncated["_hidden_contents"] = True
        return truncated, extensions_set
    try:
        items = os.listdir(root_dir)
    except PermissionError:
        logger.warning(f"Permission denied: {root_dir}")
        return structure, extensions_set
    except Exception as e:
        logger.exception(f"Error reading directory {root_dir}: {e}")
        return structure, extensions_set
    for item in items:
        item_path = os.path.join(root_dir, item)
        if item in exclude_dirs or should_exclude(
            item_path,
            ignore_context,
            exclude_extensions,
            exclude_patterns,
            include_patterns,
        ):
            continue
        if not os.path.isdir(item_path):
            _, ext = os.path.splitext(item)
            if ext.lower() not in exclude_extensions:
                if "_files" not in structure:
                    structure["_files"] = []
                file_loc = 0
                file_size = 0
                file_mtime = 0.0
                if sort_by_loc:
                    file_loc = count_lines_of_code(item_path)
                    total_loc += file_loc
                if sort_by_size:
                    file_size = get_file_size(item_path)
                    total_size += file_size
                if sort_by_mtime:
                    file_mtime = get_file_mtime(item_path)
                    latest_mtime = max(latest_mtime, file_mtime)
                if show_full_path:
                    display = os.path.abspath(item_path).replace(os.sep, "/")
                else:
                    display = item
                structure["_files"].append(
                    FileEntry(
                        name=item,
                        path=display,
                        loc=file_loc,
                        size=file_size,
                        mtime=file_mtime,
                    )
                )
                if ext:
                    extensions_set.add(ext.lower())
    try:
        st = os.stat(root_dir)
        child_ancestor_ids = ancestor_ids | {(st.st_dev, st.st_ino)}
    except OSError:
        child_ancestor_ids = ancestor_ids
    for item in items:
        item_path = os.path.join(root_dir, item)
        if item in exclude_dirs or should_exclude(
            item_path,
            ignore_context,
            exclude_extensions,
            exclude_patterns,
            include_patterns,
        ):
            continue
        if os.path.isdir(item_path):
            try:
                item_st = os.stat(item_path)
                item_id: tuple[int, int] | None = (item_st.st_dev, item_st.st_ino)
            except OSError:
                item_id = None
            if item_id is not None and item_id in child_ancestor_ids:
                logger.warning(
                    f"Skipping symlink cycle: {item_path} resolves to an ancestor"
                )
                structure[item] = {"_symlink_loop": True}
                continue
            next_path = os.path.join(current_path, item) if current_path else item
            substructure, sub_extensions = get_directory_structure(
                item_path,
                exclude_dirs,
                ignore_file,
                exclude_extensions,
                ignore_stack,
                exclude_patterns,
                include_patterns,
                max_depth,
                current_depth + 1,
                next_path,
                show_full_path,
                sort_by_loc,
                sort_by_size,
                sort_by_mtime,
                show_git_status,
                git_status_map,
                child_ancestor_ids,
            )
            if include_patterns and not (
                substructure.get("_files")
                or substructure.get("_max_depth_reached")
                or any(not k.startswith("_") for k in substructure)
            ):
                continue
            structure[item] = substructure
            extensions_set.update(sub_extensions)
            if sort_by_loc and "_loc" in substructure:
                total_loc += substructure["_loc"]
            if sort_by_size and "_size" in substructure:
                total_size += substructure["_size"]
            if sort_by_mtime and "_mtime" in substructure:
                latest_mtime = max(latest_mtime, substructure["_mtime"])
    if sort_by_loc:
        structure["_loc"] = total_loc
    if sort_by_size:
        structure["_size"] = total_size
    if sort_by_mtime:
        structure["_mtime"] = latest_mtime

    if show_git_status and git_markers:
        existing_names = {f.name for f in structure.get("_files", [])}

        for fname, status in git_markers.items():
            if status == "D" and fname not in existing_names:
                _, ext = os.path.splitext(fname)
                if ext:
                    extensions_set.add(ext.lower())
                if "_files" not in structure:
                    structure["_files"] = []
                abs_deleted = os.path.abspath(os.path.join(root_dir, fname)).replace(
                    os.sep, "/"
                )
                display = abs_deleted if show_full_path else fname
                structure["_files"].append(FileEntry(name=fname, path=display))

        structure["_git_markers"] = git_markers

    return structure, extensions_set

has_contents(structure)

Return whether a directory-structure entry holds anything to display.

A directory counts as non-empty when it has files, has subdirectories, or was cut short by the depth limit with contents left unexplored. Renderers use this to pick between the open and closed folder icons.

Parameters:

Name Type Description Default
structure Any

A subtree of a structure dict as produced by :func:get_directory_structure.

required

Returns:

Type Description
bool

True if the entry has visible contents.

Source code in recursivist/scanner.py
def has_contents(structure: Any) -> bool:
    """Return whether a directory-structure entry holds anything to display.

    A directory counts as non-empty when it has files, has subdirectories, or
    was cut short by the depth limit with contents left unexplored. Renderers
    use this to pick between the open and closed folder icons.

    Args:
        structure: A subtree of a structure dict as produced by
            :func:`get_directory_structure`.

    Returns:
        ``True`` if the entry has visible contents.
    """
    if not isinstance(structure, dict):
        return False
    if structure.get("_max_depth_reached"):
        return bool(structure.get("_hidden_contents"))
    if structure.get("_symlink_loop"):
        return True
    if structure.get("_files"):
        return True
    return any(True for _ in iter_subdirectories(structure))

iter_subdirectories(structure)

Yield (name, content) for each real subdirectory in structure.

Reserved bookkeeping keys (see :data:RESERVED_KEYS) are skipped, and entries are yielded in case-sensitive name order.

Parameters:

Name Type Description Default
structure dict[str, Any]

A directory-structure dict as produced by :func:get_directory_structure.

required

Yields:

Type Description
tuple[str, Any]

(subdirectory_name, subdirectory_content) pairs.

Source code in recursivist/scanner.py
def iter_subdirectories(structure: dict[str, Any]) -> Iterator[tuple[str, Any]]:
    """Yield ``(name, content)`` for each real subdirectory in *structure*.

    Reserved bookkeeping keys (see :data:`RESERVED_KEYS`) are skipped, and
    entries are yielded in case-sensitive name order.

    Args:
        structure: A directory-structure dict as produced by
            :func:`get_directory_structure`.

    Yields:
        ``(subdirectory_name, subdirectory_content)`` pairs.
    """
    for name in sorted(k for k in structure if k not in RESERVED_KEYS):
        yield name, structure[name]

Tree Rendering

recursivist.tree

Terminal tree rendering.

Builds and prints a rich tree from a scanned structure, with extension colors, optional metric annotations, and Git status markers. This is the top of the dependency stack, composing the scanner, filtering, colors, metrics, sorting, and icon modules.

build_tree(structure, tree, color_map, spec, show_full_path=False, icon_style='emoji')

Populate a rich tree from a scanned directory structure.

Recursively adds each file and subdirectory of structure to tree, with filenames colored by extension. Files are ordered by spec.sort_key via :func:recursivist.sorting.sort_files_by_type. A subtree that hit the depth limit is simply left unexpanded; its folder icon still shows whether anything was cut off.

The resolved spec controls the annotations appended to each entry:

  • spec.metrics: the ordered lines-of-code, size, and modification-time metrics to append (in the exact order requested).
  • spec.show_git_status: append a colored marker to each file — [U] untracked (grey), [M] modified (yellow), [A] added (green), [D] deleted (red). The marker always trails the metric parenthetical, and deleted files no longer on disk are also struck through.

Parameters:

Name Type Description Default
structure dict[str, Any]

Directory-structure dict to render.

required
tree Tree

rich tree to add nodes to. Modified in place.

required
color_map dict[str, str]

Mapping of lowercase file extension to hex color.

required
spec DisplayOptions

Resolved sorting and annotation directives.

required
show_full_path bool

Whether to display absolute paths instead of bare filenames.

False
icon_style str

Icon style to use, either "emoji" or "nerd".

'emoji'
Source code in recursivist/tree.py
def build_tree(
    structure: dict[str, Any],
    tree: Tree,
    color_map: dict[str, str],
    spec: DisplayOptions,
    show_full_path: bool = False,
    icon_style: str = "emoji",
) -> None:
    """Populate a ``rich`` tree from a scanned directory structure.

    Recursively adds each file and subdirectory of *structure* to *tree*, with
    filenames colored by extension. Files are ordered by ``spec.sort_key`` via
    :func:`recursivist.sorting.sort_files_by_type`. A subtree that hit the
    depth limit is simply left unexpanded; its folder icon still shows whether
    anything was cut off.

    The resolved *spec* controls the annotations appended to each entry:

    - ``spec.metrics``: the ordered lines-of-code, size, and modification-time
      metrics to append (in the exact order requested).
    - ``spec.show_git_status``: append a colored marker to each file — ``[U]``
      untracked (grey), ``[M]`` modified (yellow), ``[A]`` added (green),
      ``[D]`` deleted (red). The marker always trails the metric parenthetical,
      and deleted files no longer on disk are also struck through.

    Args:
        structure: Directory-structure dict to render.
        tree: ``rich`` tree to add nodes to. Modified in place.
        color_map: Mapping of lowercase file extension to hex color.
        spec: Resolved sorting and annotation directives.
        show_full_path: Whether to display absolute paths instead of bare
            filenames.
        icon_style: Icon style to use, either ``"emoji"`` or ``"nerd"``.
    """
    _GIT_MARKER_STYLES = {
        "U": ("dim", "[U]"),
        "M": ("yellow", "[M]"),
        "A": ("green", "[A]"),
        "D": ("red", "[D]"),
    }
    need_git = spec.show_git_status or spec.sort_key == METRIC_GIT
    git_markers_dict: dict[str, str] = (
        structure.get("_git_markers", {}) if need_git else {}
    )
    if "_files" in structure:
        for entry in sort_files_by_type(
            structure["_files"], spec.sort_key, git_markers_dict
        ):
            display_path = entry.path if show_full_path else entry.name
            ext = os.path.splitext(entry.name)[1].lower()
            color = color_map.get(ext, "#FFFFFF")

            git_marker = git_markers_dict.get(entry.name, "")
            is_deleted = git_marker == "D"

            name_style = f"{color} strike" if is_deleted else color

            colored_text = Text()
            icon = get_icon(entry.name, is_dir=False, style=icon_style)
            colored_text.append(f"{icon} ", style=color)
            colored_text.append(
                display_path
                + format_metrics_suffix(
                    entry.loc, entry.size, entry.mtime, spec.metrics
                ),
                style=name_style,
            )

            if spec.show_git_status and git_marker:
                marker_style, badge = _GIT_MARKER_STYLES.get(
                    git_marker, ("dim", f"[{git_marker}]")
                )
                colored_text.append(f" {badge}", style=marker_style)

            tree.add(colored_text)
    for folder, content in iter_subdirectories(structure):
        folder_icon = get_icon(
            folder,
            is_dir=True,
            style=icon_style,
            is_empty=not has_contents(content),
        )
        metrics = format_dir_metrics(content, spec.metrics)
        folder_display = f"{folder_icon} {folder}{metrics}"
        subtree = tree.add(folder_display)
        if isinstance(content, dict) and content.get("_symlink_loop"):
            subtree.add(Text("↩ (symlink loop)", style="dim"))
        elif not (isinstance(content, dict) and content.get("_max_depth_reached")):
            build_tree(content, subtree, color_map, spec, show_full_path, icon_style)

display_tree(root_dir, exclude_dirs=None, ignore_file=None, exclude_extensions=None, exclude_patterns=None, include_patterns=None, use_regex=False, max_depth=0, show_full_path=False, spec=None, icon_style='emoji', structure=None, extensions=None, root_name=None)

Scan a directory and render it as a tree in the terminal.

Runs the full pipeline — optionally fetching Git status, scanning the directory, building a color map, and printing a rich tree — unless a pre-computed structure and extensions are supplied, in which case the scan is skipped and those are rendered directly.

Parameters:

Name Type Description Default
root_dir str

Directory to display.

required
exclude_dirs list[str] | None

Directory names to skip entirely.

None
ignore_file str | None

Name of an ignore file to honor (e.g. .gitignore).

None
exclude_extensions set[str] | None

File extensions to exclude. Normalized to a lowercase, dot-prefixed form before scanning.

None
exclude_patterns list[str] | None

Glob or regex patterns to exclude.

None
include_patterns list[str] | None

Glob or regex patterns to include, which override the exclusions.

None
use_regex bool

Whether to treat the patterns as regular expressions instead of glob patterns.

False
max_depth int

Maximum depth to display, or 0 for unlimited.

0
show_full_path bool

Whether to display absolute paths instead of bare filenames.

False
spec DisplayOptions | None

Resolved sorting and annotation directives. Defaults to a plain :class:DisplayOptions (no sorting, no annotations).

None
icon_style str

Icon style to use, either "emoji" or "nerd".

'emoji'
structure dict[str, Any] | None

Pre-computed directory structure. When given together with extensions, the directory is not re-scanned.

None
extensions set[str] | None

Pre-computed set of file extensions matching structure.

None
root_name str | None

Display name for the root node. Defaults to the basename of root_dir; supply this to label the tree with something other than the scanned path (e.g. a repository name for a GitHub input).

None
Source code in recursivist/tree.py
def display_tree(
    root_dir: str,
    exclude_dirs: list[str] | None = None,
    ignore_file: str | None = None,
    exclude_extensions: set[str] | None = None,
    exclude_patterns: list[str] | None = None,
    include_patterns: list[str] | None = None,
    use_regex: bool = False,
    max_depth: int = 0,
    show_full_path: bool = False,
    spec: DisplayOptions | None = None,
    icon_style: str = "emoji",
    structure: dict[str, Any] | None = None,
    extensions: set[str] | None = None,
    root_name: str | None = None,
) -> None:
    """Scan a directory and render it as a tree in the terminal.

    Runs the full pipeline — optionally fetching Git status, scanning the
    directory, building a color map, and printing a ``rich`` tree — unless a
    pre-computed *structure* and *extensions* are supplied, in which case the
    scan is skipped and those are rendered directly.

    Args:
        root_dir: Directory to display.
        exclude_dirs: Directory names to skip entirely.
        ignore_file: Name of an ignore file to honor (e.g. ``.gitignore``).
        exclude_extensions: File extensions to exclude. Normalized to a
            lowercase, dot-prefixed form before scanning.
        exclude_patterns: Glob or regex patterns to exclude.
        include_patterns: Glob or regex patterns to include, which override
            the exclusions.
        use_regex: Whether to treat the patterns as regular expressions
            instead of glob patterns.
        max_depth: Maximum depth to display, or ``0`` for unlimited.
        show_full_path: Whether to display absolute paths instead of bare
            filenames.
        spec: Resolved sorting and annotation directives. Defaults to a plain
            :class:`DisplayOptions` (no sorting, no annotations).
        icon_style: Icon style to use, either ``"emoji"`` or ``"nerd"``.
        structure: Pre-computed directory structure. When given together with
            *extensions*, the directory is not re-scanned.
        extensions: Pre-computed set of file extensions matching *structure*.
        root_name: Display name for the root node. Defaults to the basename of
            *root_dir*; supply this to label the tree with something other than
            the scanned path (e.g. a repository name for a GitHub input).
    """
    if exclude_dirs is None:
        exclude_dirs = []
    if exclude_extensions is None:
        exclude_extensions = set()
    if exclude_patterns is None:
        exclude_patterns = []
    if include_patterns is None:
        include_patterns = []
    if spec is None:
        spec = DisplayOptions()

    if structure is None or extensions is None:
        exclude_extensions = {
            ext.lower() if ext.startswith(".") else f".{ext.lower()}"
            for ext in exclude_extensions
        }
        compiled_exclude = compile_regex_patterns(exclude_patterns, use_regex)
        compiled_include = compile_regex_patterns(include_patterns, use_regex)

        git_status_map: dict[str, str] | None = None
        if spec.show_git_status:
            git_status_map = get_git_status(root_dir)
            if not git_status_map:
                logger.debug(
                    "Git status requested but no data returned — "
                    "directory may not be inside a Git repository, or there are no changes."
                )

        structure, extensions = get_directory_structure(
            root_dir=root_dir,
            exclude_dirs=exclude_dirs,
            ignore_file=ignore_file,
            exclude_extensions=exclude_extensions,
            parent_ignore_patterns=None,
            exclude_patterns=compiled_exclude,
            include_patterns=compiled_include,
            max_depth=max_depth,
            show_full_path=show_full_path,
            sort_by_loc=spec.show_loc,
            sort_by_size=spec.show_size,
            sort_by_mtime=spec.show_mtime,
            show_git_status=spec.show_git_status,
            git_status_map=git_status_map,
        )
    color_map = {ext: generate_color_for_extension(ext) for ext in extensions}
    console = Console()

    root_base = root_name if root_name is not None else os.path.basename(root_dir)
    root_icon = get_icon(
        root_base,
        is_dir=True,
        style=icon_style,
        is_empty=not has_contents(structure),
    )
    root_label = f"{root_icon} {root_base}" + format_dir_metrics(
        structure, spec.metrics
    )
    tree = Tree(root_label)
    build_tree(
        structure,
        tree,
        color_map,
        spec,
        show_full_path=show_full_path,
        icon_style=icon_style,
    )
    console.print(tree)

Exporters

Exports go through the get_exporter factory, which returns a BaseExporter subclass for the requested format. Call its export method with an output path.

recursivist.exporters

Exporter registry.

Maps each supported export format to its exporter class and exposes :func:get_exporter, the factory used to construct the right exporter for a requested format.

canonical_extension(format_type)

Return the canonical output file extension for a format identifier.

The extension is read from the exporter class, so aliases that share an exporter collapse to a single extension.

Parameters:

Name Type Description Default
format_type str

Export format identifier (e.g. "json" or "markdown"). Matched case-insensitively.

required

Returns:

Type Description
str

The exporter's canonical file extension, without a leading dot (e.g.

str

"md" for both "md" and "markdown"). Falls back to the

str

lowercased format_type for unknown formats, mirroring

str

func:get_exporter's lookup.

Source code in recursivist/exporters/__init__.py
def canonical_extension(format_type: str) -> str:
    """Return the canonical output file extension for a format identifier.

    The extension is read from the exporter class, so aliases that share an
    exporter collapse to a single extension.

    Args:
        format_type: Export format identifier (e.g. ``"json"`` or
            ``"markdown"``). Matched case-insensitively.

    Returns:
        The exporter's canonical file extension, without a leading dot (e.g.
        ``"md"`` for both ``"md"`` and ``"markdown"``). Falls back to the
        lowercased *format_type* for unknown formats, mirroring
        :func:`get_exporter`'s lookup.
    """
    exporter_class = _EXPORTERS.get(format_type.lower())
    if exporter_class is not None and exporter_class.extension:
        return exporter_class.extension
    return format_type.lower()

get_exporter(format_type, **kwargs)

Construct the exporter for a given format.

Parameters:

Name Type Description Default
format_type str

Export format identifier (e.g. "json" or "txt"). Matched case-insensitively; "md" and "markdown" are equivalent.

required
**kwargs Any

Keyword arguments forwarded to the exporter's constructor (see :class:BaseExporter).

{}

Returns:

Type Description
BaseExporter

A ready-to-use exporter instance; call its export method to write

BaseExporter

the output file.

Raises:

Type Description
ValueError

If format_type is not a supported format.

Source code in recursivist/exporters/__init__.py
def get_exporter(format_type: str, **kwargs: Any) -> BaseExporter:
    """Construct the exporter for a given format.

    Args:
        format_type: Export format identifier (e.g. ``"json"`` or ``"txt"``).
            Matched case-insensitively; ``"md"`` and ``"markdown"`` are
            equivalent.
        **kwargs: Keyword arguments forwarded to the exporter's constructor
            (see :class:`BaseExporter`).

    Returns:
        A ready-to-use exporter instance; call its ``export`` method to write
        the output file.

    Raises:
        ValueError: If *format_type* is not a supported format.
    """
    exporter_class = _EXPORTERS.get(format_type.lower())
    if not exporter_class:
        raise ValueError(
            f"Unsupported export format: {format_type}. "
            f"Supported formats: {', '.join(_EXPORTERS.keys())}"
        )

    return exporter_class(**kwargs)

supported_formats()

Return the export format identifiers accepted by :func:get_exporter.

This is the single source of truth for which formats are valid; callers (e.g. the CLI) should derive their validation from it rather than hard-coding a list.

Returns:

Type Description
list[str]

The supported format identifiers, sorted for stable presentation.

list[str]

Alias keys are included, so both "md" and "markdown" appear.

Source code in recursivist/exporters/__init__.py
def supported_formats() -> list[str]:
    """Return the export format identifiers accepted by :func:`get_exporter`.

    This is the single source of truth for which formats are valid; callers
    (e.g. the CLI) should derive their validation from it rather than
    hard-coding a list.

    Returns:
        The supported format identifiers, sorted for stable presentation.
        Alias keys are included, so both ``"md"`` and ``"markdown"`` appear.
    """
    return sorted(_EXPORTERS)

recursivist.exporters.base

Shared base class for directory-structure exporters.

Defines :class:BaseExporter, which stores the scanned structure and the resolved display options common to every output format. Concrete exporters subclass it and implement :meth:BaseExporter.export.

BaseExporter

Common base for the per-format exporters.

Holds the scanned structure and the resolved :class:DisplayOptions; the actual output is produced by each subclass's :meth:export. For convenience, the individual pieces of the spec are also exposed as plain attributes (metrics, sort_key, show_loc/show_size/ show_mtime/show_git_status) so exporters can read them directly.

Attributes:

Name Type Description
extension str

Canonical file extension for this format, without a leading dot (e.g. "md"). Set by each concrete subclass and used as the single source of truth for output filenames, so format aliases that share an exporter (such as "md" and "markdown") resolve to the same extension.

Source code in recursivist/exporters/base.py
class BaseExporter:
    """Common base for the per-format exporters.

    Holds the scanned structure and the resolved :class:`DisplayOptions`; the
    actual output is produced by each subclass's :meth:`export`. For
    convenience, the individual pieces of the spec are also exposed as plain
    attributes (``metrics``, ``sort_key``, ``show_loc``/``show_size``/
    ``show_mtime``/``show_git_status``) so exporters can read them directly.

    Attributes:
        extension: Canonical file extension for this format, without a leading
            dot (e.g. ``"md"``). Set by each concrete subclass and used as the
            single source of truth for output filenames, so format aliases that
            share an exporter (such as ``"md"`` and ``"markdown"``) resolve to
            the same extension.
    """

    extension: str = ""

    def __init__(
        self,
        structure: dict[str, Any],
        root_name: str,
        base_path: str | None = None,
        spec: DisplayOptions | None = None,
        icon_style: str = "emoji",
    ) -> None:
        """Store the structure and display options for an export.

        Args:
            structure: Scanned directory structure to export.
            root_name: Display name of the root directory.
            base_path: Base path for full-path display. When provided (not
                ``None``), absolute paths are shown instead of bare filenames.
            spec: Resolved sorting and annotation directives. Defaults to a
                plain :class:`DisplayOptions` (no sorting, no annotations).
            icon_style: Icon style to use, either ``"emoji"`` or ``"nerd"``.
        """
        self.structure = structure
        self.root_name = root_name
        self.base_path = base_path
        self.show_full_path = base_path is not None
        self.spec = spec if spec is not None else DisplayOptions()
        self.metrics = self.spec.metrics
        self.sort_key = self.spec.sort_key
        self.show_loc = self.spec.show_loc
        self.show_size = self.spec.show_size
        self.show_mtime = self.spec.show_mtime
        self.show_git_status = self.spec.show_git_status
        self.icon_style = icon_style

    def export(self, output_path: str) -> None:
        """Write the export to *output_path*.

        Args:
            output_path: Path the output file is written to.

        Raises:
            NotImplementedError: Always; subclasses must override this method.
        """
        raise NotImplementedError("Subclasses must implement the export method.")

__init__(structure, root_name, base_path=None, spec=None, icon_style='emoji')

Store the structure and display options for an export.

Parameters:

Name Type Description Default
structure dict[str, Any]

Scanned directory structure to export.

required
root_name str

Display name of the root directory.

required
base_path str | None

Base path for full-path display. When provided (not None), absolute paths are shown instead of bare filenames.

None
spec DisplayOptions | None

Resolved sorting and annotation directives. Defaults to a plain :class:DisplayOptions (no sorting, no annotations).

None
icon_style str

Icon style to use, either "emoji" or "nerd".

'emoji'
Source code in recursivist/exporters/base.py
def __init__(
    self,
    structure: dict[str, Any],
    root_name: str,
    base_path: str | None = None,
    spec: DisplayOptions | None = None,
    icon_style: str = "emoji",
) -> None:
    """Store the structure and display options for an export.

    Args:
        structure: Scanned directory structure to export.
        root_name: Display name of the root directory.
        base_path: Base path for full-path display. When provided (not
            ``None``), absolute paths are shown instead of bare filenames.
        spec: Resolved sorting and annotation directives. Defaults to a
            plain :class:`DisplayOptions` (no sorting, no annotations).
        icon_style: Icon style to use, either ``"emoji"`` or ``"nerd"``.
    """
    self.structure = structure
    self.root_name = root_name
    self.base_path = base_path
    self.show_full_path = base_path is not None
    self.spec = spec if spec is not None else DisplayOptions()
    self.metrics = self.spec.metrics
    self.sort_key = self.spec.sort_key
    self.show_loc = self.spec.show_loc
    self.show_size = self.spec.show_size
    self.show_mtime = self.spec.show_mtime
    self.show_git_status = self.spec.show_git_status
    self.icon_style = icon_style

export(output_path)

Write the export to output_path.

Parameters:

Name Type Description Default
output_path str

Path the output file is written to.

required

Raises:

Type Description
NotImplementedError

Always; subclasses must override this method.

Source code in recursivist/exporters/base.py
def export(self, output_path: str) -> None:
    """Write the export to *output_path*.

    Args:
        output_path: Path the output file is written to.

    Raises:
        NotImplementedError: Always; subclasses must override this method.
    """
    raise NotImplementedError("Subclasses must implement the export method.")

Compare

recursivist.compare

Side-by-side directory comparison.

Builds the structures for two directories with identical filtering and renders them next to each other, highlighting entries unique to either side. Supports the same filtering and metric options as the single-tree renderer, with terminal output for interactive use and HTML export for sharing.

build_comparison_tree(structure, other_structure, tree, spec, show_full_path=False, icon_style='emoji', identity_spec=None, *, this_is_remote=False, other_is_remote=False)

Populate a rich tree, highlighting differences against another tree.

Recursively adds the entries of structure to tree, comparing each against other_structure: items present in both are shown normally, items unique to structure are highlighted in green, and items unique to other_structure are highlighted in red. File names are rendered without file-type-specific colors so the green/red difference highlighting stands out. Files are ordered by spec.sort_key and metric annotations are appended in spec.metrics order.

When spec.show_git_status is set, each file is followed by a plain Git-status badge — [U] untracked, [M] modified, [A] added, [D] deleted — read from the _git_markers stored on structure (and on other_structure for entries unique to it). The badge is not color-coded; it trails the metric parenthetical, and deleted files are struck through.

Two identically named files count as the same entry only when their displayed annotations also match (see :func:_comparison_identity), so a differing metric or Git status marks them as unique to their side. identity_spec controls which annotations that match considers: it defaults to spec, but a caller comparing a local directory against a hosted repository passes spec.without_remote_unsupported() so that annotations a remote side cannot provide (modification time, Git status) are excluded from the identity — those are still displayed per spec, they just no longer split otherwise-matching files across the two sides.

Parameters:

Name Type Description Default
structure dict[str, Any]

Structure of the directory being rendered.

required
other_structure dict[str, Any]

Structure of the directory being compared against.

required
tree Tree

rich tree to add nodes to. Modified in place.

required
spec DisplayOptions

Resolved sorting and annotation directives.

required
show_full_path bool

Whether to display absolute paths instead of bare filenames.

False
icon_style str

Icon style to use, either "emoji" or "nerd".

'emoji'
identity_spec DisplayOptions | None

Directives governing which annotations contribute to cross-side file identity. Defaults to spec.

None
this_is_remote bool

Whether the primary structure originates from a hosted repository.

False
other_is_remote bool

Whether the compared structure originates from a hosted repository.

False
Source code in recursivist/compare.py
def build_comparison_tree(
    structure: dict[str, Any],
    other_structure: dict[str, Any],
    tree: Tree,
    spec: DisplayOptions,
    show_full_path: bool = False,
    icon_style: str = "emoji",
    identity_spec: DisplayOptions | None = None,
    *,
    this_is_remote: bool = False,
    other_is_remote: bool = False,
) -> None:
    """Populate a ``rich`` tree, highlighting differences against another tree.

    Recursively adds the entries of *structure* to *tree*, comparing each
    against *other_structure*: items present in both are shown normally, items
    unique to *structure* are highlighted in green, and items unique to
    *other_structure* are highlighted in red. File names are rendered without
    file-type-specific colors so the green/red difference highlighting stands
    out. Files are ordered by ``spec.sort_key`` and metric annotations are
    appended in ``spec.metrics`` order.

    When ``spec.show_git_status`` is set, each file is followed by a plain
    Git-status badge — ``[U]`` untracked, ``[M]`` modified, ``[A]`` added,
    ``[D]`` deleted — read from the ``_git_markers`` stored on *structure* (and
    on *other_structure* for entries unique to it). The badge is not
    color-coded; it trails the metric parenthetical, and deleted files are
    struck through.

    Two identically named files count as the same entry only when their
    *displayed* annotations also match (see :func:`_comparison_identity`), so a
    differing metric or Git status marks them as unique to their side.
    *identity_spec* controls which annotations that match considers: it
    defaults to *spec*, but a caller comparing a local directory against a
    hosted repository passes ``spec.without_remote_unsupported()`` so that
    annotations a remote side cannot provide (modification time, Git status)
    are excluded from the identity — those are still *displayed* per *spec*,
    they just no longer split otherwise-matching files across the two sides.

    Args:
        structure: Structure of the directory being rendered.
        other_structure: Structure of the directory being compared against.
        tree: ``rich`` tree to add nodes to. Modified in place.
        spec: Resolved sorting and annotation directives.
        show_full_path: Whether to display absolute paths instead of bare
            filenames.
        icon_style: Icon style to use, either ``"emoji"`` or ``"nerd"``.
        identity_spec: Directives governing which annotations contribute to
            cross-side file identity. Defaults to *spec*.
        this_is_remote: Whether the primary structure originates from a hosted repository.
        other_is_remote: Whether the compared structure originates from a hosted repository.
    """
    id_spec = identity_spec if identity_spec is not None else spec
    need_git = spec.show_git_status or spec.sort_key == METRIC_GIT
    git_markers_dict: dict[str, str] = (
        structure.get("_git_markers", {}) if need_git else {}
    )
    other_git_markers: dict[str, str] = (
        other_structure.get("_git_markers", {}) if need_git and other_structure else {}
    )

    this_metrics = (
        tuple(m for m in spec.metrics if m != "mtime")
        if this_is_remote
        else spec.metrics
    )
    other_metrics = (
        tuple(m for m in spec.metrics if m != "mtime")
        if other_is_remote
        else spec.metrics
    )

    def _add_file_node(
        entry: Any, markers: dict[str, str], highlight: str, metrics: Sequence[str]
    ) -> None:
        """Add a single file entry to *tree* with metrics and Git badge.

        The Git badge (``[U]``/``[M]``/``[A]``/``[D]``) is rendered without any
        color of its own; deleted files are struck through.

        Args:
            entry: The :class:`FileEntry` to render.
            markers: The ``{filename: status_char}`` map for this file's side.
            highlight: The background highlight style (``"on green"``,
                ``"on red"``, or ``""``) marking difference state.
            metrics: Displayed metrics for the file.
        """
        file_icon = get_icon(entry.name, is_dir=False, style=icon_style)
        label = f"{file_icon} {entry.path}" + format_metrics_suffix(
            entry.loc, entry.size, entry.mtime, metrics
        )
        git_marker = markers.get(entry.name, "") if need_git else ""
        if git_marker == "D":
            name_style = f"{highlight} strike".strip()
        else:
            name_style = highlight
        text = Text(label, style=name_style)
        if spec.show_git_status and git_marker:
            text.append(f" [{git_marker}]", style=highlight)
        tree.add(text)

    if "_files" in structure:
        files_in_other = other_structure.get("_files", []) if other_structure else []
        other_identities = {
            _comparison_identity(
                FileEntry.coerce(item),
                id_spec.metrics,
                id_spec.show_git_status,
                other_git_markers,
            )
            for item in files_in_other
        }
        for entry in sort_files_by_type(
            structure["_files"], spec.sort_key, git_markers_dict
        ):
            identity = _comparison_identity(
                entry, id_spec.metrics, id_spec.show_git_status, git_markers_dict
            )
            highlight = "on green" if identity not in other_identities else ""
            _add_file_node(entry, git_markers_dict, highlight, this_metrics)
    for folder, content in iter_subdirectories(structure):
        other_content = other_structure.get(folder, {}) if other_structure else {}
        folder_icon = get_icon(
            folder,
            is_dir=True,
            style=icon_style,
            is_empty=not (has_contents(content) or has_contents(other_content)),
        )

        metrics_suffix = format_dir_metrics(content, this_metrics)
        folder_label = f"{folder_icon} {folder}{metrics_suffix}"
        if folder not in (other_structure or {}):
            subtree = tree.add(Text(folder_label, style="green"))
        else:
            subtree = tree.add(folder_label)
        if isinstance(content, dict) and content.get("_symlink_loop"):
            subtree.add(Text("↩ (symlink loop)", style="dim"))
        elif not (isinstance(content, dict) and content.get("_max_depth_reached")):
            build_comparison_tree(
                content,
                other_content,
                subtree,
                spec,
                show_full_path,
                icon_style=icon_style,
                identity_spec=id_spec,
                this_is_remote=this_is_remote,
                other_is_remote=other_is_remote,
            )
    if other_structure and "_files" in other_structure:
        files_in_this = structure.get("_files", [])
        this_identities = {
            _comparison_identity(
                FileEntry.coerce(item),
                id_spec.metrics,
                id_spec.show_git_status,
                git_markers_dict,
            )
            for item in files_in_this
        }
        for entry in sort_files_by_type(
            other_structure["_files"], spec.sort_key, other_git_markers
        ):
            identity = _comparison_identity(
                entry, id_spec.metrics, id_spec.show_git_status, other_git_markers
            )
            if identity not in this_identities:
                _add_file_node(entry, other_git_markers, "on red", other_metrics)
    if other_structure:
        for folder, other_content in iter_subdirectories(other_structure):
            if folder in structure:
                continue
            folder_icon = get_icon(
                folder,
                is_dir=True,
                style=icon_style,
                is_empty=not has_contents(other_content),
            )

            metrics_suffix = format_dir_metrics(other_content, other_metrics)
            subtree = tree.add(
                Text(f"{folder_icon} {folder}{metrics_suffix}", style="red")
            )
            if isinstance(other_content, dict) and other_content.get("_symlink_loop"):
                subtree.add(Text("↩ (symlink loop)", style="dim"))
            elif not (
                isinstance(other_content, dict)
                and other_content.get("_max_depth_reached")
            ):
                build_comparison_tree(
                    {},
                    other_content,
                    subtree,
                    spec,
                    show_full_path,
                    icon_style=icon_style,
                    identity_spec=id_spec,
                    this_is_remote=this_is_remote,
                    other_is_remote=other_is_remote,
                )

compare_directory_structures(dir1, dir2, exclude_dirs=None, ignore_file=None, exclude_extensions=None, exclude_patterns=None, include_patterns=None, max_depth=0, show_full_path=False, spec=None)

Scan two inputs for comparison, each a local directory or GitHub URL.

Each side is scanned with the same filtering and metric settings. A side may be a local directory or a GitHub repository URL; a GitHub side is downloaded and extracted to a temporary directory (removed before this function returns), scanned there, and — when show_full_path is set — has its file paths rewritten to GitHub blob URLs.

The --ignore-file option, Git-status annotations, and modification-time annotations only apply to local directories, so they are skipped for any GitHub side (its spec is adjusted via :meth:~recursivist.flags.DisplayOptions.without_remote_unsupported) while still being honored for a local side. When both sides are GitHub repositories the caller is expected to have already cleared these from spec as well.

Parameters:

Name Type Description Default
dir1 str

First input — a local directory path or a GitHub repository URL.

required
dir2 str

Second input — a local directory path or a GitHub repository URL.

required
exclude_dirs Sequence[str] | None

Directory names to skip entirely.

None
ignore_file str | None

Name of an ignore file to honor for local sides (e.g. .gitignore); ignored for GitHub sides.

None
exclude_extensions set[str] | None

Lowercase, dot-prefixed extensions to exclude.

None
exclude_patterns Sequence[str | Pattern[str]] | None

Glob or compiled-regex patterns to exclude.

None
include_patterns Sequence[str | Pattern[str]] | None

Glob or compiled-regex patterns to include, which override the exclusions.

None
max_depth int

Maximum depth to scan, or 0 for unlimited.

0
show_full_path bool

Whether to store absolute paths (local sides) or GitHub blob URLs (GitHub sides) instead of bare filenames.

False
spec DisplayOptions | None

Resolved sorting and annotation directives. When Git status is requested it is looked up independently for each local side. Defaults to a plain :class:DisplayOptions.

None

Returns:

Type Description
tuple[dict[str, Any], dict[str, Any]]

A (structure1, structure2) tuple holding each input's structure.

Source code in recursivist/compare.py
def compare_directory_structures(
    dir1: str,
    dir2: str,
    exclude_dirs: Sequence[str] | None = None,
    ignore_file: str | None = None,
    exclude_extensions: set[str] | None = None,
    exclude_patterns: Sequence[str | Pattern[str]] | None = None,
    include_patterns: Sequence[str | Pattern[str]] | None = None,
    max_depth: int = 0,
    show_full_path: bool = False,
    spec: DisplayOptions | None = None,
) -> tuple[dict[str, Any], dict[str, Any]]:
    """Scan two inputs for comparison, each a local directory or GitHub URL.

    Each side is scanned with the same filtering and metric settings. A side
    may be a local directory or a GitHub repository URL; a GitHub side is
    downloaded and extracted to a temporary directory (removed before this
    function returns), scanned there, and — when *show_full_path* is set — has
    its file paths rewritten to GitHub blob URLs.

    The ``--ignore-file`` option, Git-status annotations, and modification-time
    annotations only apply to local directories, so they are skipped for any
    GitHub side (its spec is adjusted via
    :meth:`~recursivist.flags.DisplayOptions.without_remote_unsupported`) while
    still being honored for a local side. When *both* sides are GitHub
    repositories the caller is expected to have already cleared these from
    *spec* as well.

    Args:
        dir1: First input — a local directory path or a GitHub repository URL.
        dir2: Second input — a local directory path or a GitHub repository URL.
        exclude_dirs: Directory names to skip entirely.
        ignore_file: Name of an ignore file to honor for local sides (e.g.
            ``.gitignore``); ignored for GitHub sides.
        exclude_extensions: Lowercase, dot-prefixed extensions to exclude.
        exclude_patterns: Glob or compiled-regex patterns to exclude.
        include_patterns: Glob or compiled-regex patterns to include, which
            override the exclusions.
        max_depth: Maximum depth to scan, or ``0`` for unlimited.
        show_full_path: Whether to store absolute paths (local sides) or GitHub
            blob URLs (GitHub sides) instead of bare filenames.
        spec: Resolved sorting and annotation directives. When Git status is
            requested it is looked up independently for each *local* side.
            Defaults to a plain :class:`DisplayOptions`.

    Returns:
        A ``(structure1, structure2)`` tuple holding each input's structure.
    """
    if spec is None:
        spec = DisplayOptions()
    remote_spec = spec.without_remote_unsupported()

    target1 = parse_github_url(dir1)
    target2 = parse_github_url(dir2)

    def _side(
        stack: contextlib.ExitStack,
        raw: str,
        target: GitHubTarget | None,
    ) -> dict[str, Any]:
        if target is None:
            return _scan_one_side(
                raw,
                exclude_dirs,
                ignore_file,
                exclude_extensions,
                exclude_patterns,
                include_patterns,
                max_depth,
                show_full_path,
                spec,
            )
        checkout = stack.enter_context(checkout_repository(target))
        structure = _scan_one_side(
            checkout.local_root,
            exclude_dirs,
            None,
            exclude_extensions,
            exclude_patterns,
            include_patterns,
            max_depth,
            show_full_path,
            remote_spec,
        )
        if show_full_path:
            apply_github_urls(structure, checkout)
        return structure

    with contextlib.ExitStack() as stack:
        structure1 = _side(stack, dir1, target1)
        structure2 = _side(stack, dir2, target2)
        return structure1, structure2

display_comparison(dir1, dir2, exclude_dirs=None, ignore_file=None, exclude_extensions=None, exclude_patterns=None, include_patterns=None, use_regex=False, max_depth=0, show_full_path=False, spec=None, icon_style='emoji')

Render two directory trees side by side in the terminal.

Scans both directories with identical options and prints them as two labeled, color-highlighted panels: entries unique to dir1 and dir2 are highlighted in contrasting colors, shared entries are shown normally, and a legend explains the scheme.

Parameters:

Name Type Description Default
dir1 str

Path to the first directory.

required
dir2 str

Path to the second directory.

required
exclude_dirs list[str] | None

Directory names to skip entirely.

None
ignore_file str | None

Name of an ignore file to honor (e.g. .gitignore).

None
exclude_extensions set[str] | None

File extensions to exclude. Normalized to a lowercase, dot-prefixed form before scanning.

None
exclude_patterns list[str] | None

Glob or regex patterns to exclude.

None
include_patterns list[str] | None

Glob or regex patterns to include, which override the exclusions.

None
use_regex bool

Whether to treat the patterns as regular expressions instead of glob patterns.

False
max_depth int

Maximum depth to display, or 0 for unlimited.

0
show_full_path bool

Whether to display absolute paths instead of bare filenames.

False
spec DisplayOptions | None

Resolved sorting and annotation directives. Defaults to a plain :class:DisplayOptions.

None
icon_style str

Icon style to use, either "emoji" or "nerd".

'emoji'
Source code in recursivist/compare.py
def display_comparison(
    dir1: str,
    dir2: str,
    exclude_dirs: list[str] | None = None,
    ignore_file: str | None = None,
    exclude_extensions: set[str] | None = None,
    exclude_patterns: list[str] | None = None,
    include_patterns: list[str] | None = None,
    use_regex: bool = False,
    max_depth: int = 0,
    show_full_path: bool = False,
    spec: DisplayOptions | None = None,
    icon_style: str = "emoji",
) -> None:
    """Render two directory trees side by side in the terminal.

    Scans both directories with identical options and prints them as two
    labeled, color-highlighted panels: entries unique to *dir1* and *dir2* are
    highlighted in contrasting colors, shared entries are shown normally, and a
    legend explains the scheme.

    Args:
        dir1: Path to the first directory.
        dir2: Path to the second directory.
        exclude_dirs: Directory names to skip entirely.
        ignore_file: Name of an ignore file to honor (e.g. ``.gitignore``).
        exclude_extensions: File extensions to exclude. Normalized to a
            lowercase, dot-prefixed form before scanning.
        exclude_patterns: Glob or regex patterns to exclude.
        include_patterns: Glob or regex patterns to include, which override
            the exclusions.
        use_regex: Whether to treat the patterns as regular expressions
            instead of glob patterns.
        max_depth: Maximum depth to display, or ``0`` for unlimited.
        show_full_path: Whether to display absolute paths instead of bare
            filenames.
        spec: Resolved sorting and annotation directives. Defaults to a plain
            :class:`DisplayOptions`.
        icon_style: Icon style to use, either ``"emoji"`` or ``"nerd"``.
    """
    if spec is None:
        spec = DisplayOptions()
    if exclude_dirs is None:
        exclude_dirs = []
    if exclude_extensions is None:
        exclude_extensions = set()
    if exclude_patterns is None:
        exclude_patterns = []
    if include_patterns is None:
        include_patterns = []
    exclude_extensions = {
        ext.lower() if ext.startswith(".") else f".{ext.lower()}"
        for ext in exclude_extensions
    }
    compiled_exclude = compile_regex_patterns(exclude_patterns, use_regex)
    compiled_include = compile_regex_patterns(include_patterns, use_regex)
    structure1, structure2 = compare_directory_structures(
        dir1,
        dir2,
        exclude_dirs,
        ignore_file,
        exclude_extensions,
        exclude_patterns=compiled_exclude,
        include_patterns=compiled_include,
        max_depth=max_depth,
        show_full_path=show_full_path,
        spec=spec,
    )
    console = Console()

    identity_spec = _identity_spec_for(dir1, dir2, spec)

    is_remote1 = parse_github_url(dir1) is not None
    is_remote2 = parse_github_url(dir2) is not None

    dir1_metrics = (
        tuple(m for m in spec.metrics if m != "mtime") if is_remote1 else spec.metrics
    )
    dir2_metrics = (
        tuple(m for m in spec.metrics if m != "mtime") if is_remote2 else spec.metrics
    )

    root_base1 = _side_display_name(dir1)
    root_base2 = _side_display_name(dir2)
    root_icon1 = get_icon(
        root_base1,
        is_dir=True,
        style=icon_style,
        is_empty=not has_contents(structure1),
    )
    root_icon2 = get_icon(
        root_base2,
        is_dir=True,
        style=icon_style,
        is_empty=not has_contents(structure2),
    )

    tree1 = Tree(
        Text(
            f"{root_icon1} {root_base1}" + format_dir_metrics(structure1, dir1_metrics),
            style="bold",
        )
    )

    tree2 = Tree(
        Text(
            f"{root_icon2} {root_base2}" + format_dir_metrics(structure2, dir2_metrics),
            style="bold",
        )
    )

    build_comparison_tree(
        structure1,
        structure2,
        tree1,
        spec,
        show_full_path=show_full_path,
        icon_style=icon_style,
        identity_spec=identity_spec,
        this_is_remote=is_remote1,
        other_is_remote=is_remote2,
    )
    build_comparison_tree(
        structure2,
        structure1,
        tree2,
        spec,
        show_full_path=show_full_path,
        icon_style=icon_style,
        identity_spec=identity_spec,
        this_is_remote=is_remote2,
        other_is_remote=is_remote1,
    )
    legend_text = Text()
    legend_text.append("Legend: ", style="bold")
    legend_text.append("Green", style="on green")
    legend_text.append(" = In this directory, ")
    legend_text.append("Red", style="on red")
    legend_text.append(" = In the other directory")
    if "loc" in spec.metrics:
        legend_text.append("\n")
        legend_text.append("LOC counts shown in parentheses")
    if "size" in spec.metrics:
        legend_text.append("\n")
        legend_text.append("File sizes shown in parentheses")
    if "mtime" in spec.metrics:
        legend_text.append("\n")
        legend_text.append("Modification times shown in parentheses")
    if spec.show_git_status:
        legend_text.append("\n")
        legend_text.append(
            "Git status markers: [U] untracked, [M] modified, [A] added, [D] deleted"
        )
    _sort_note = {
        "loc": "Files sorted by line count",
        "size": "Files sorted by size",
        "mtime": "Files sorted by modification time (newest first)",
        "git_status": "Files sorted by Git status",
        "similarity": "Files grouped by name similarity",
    }.get(spec.sort_key or "")
    if _sort_note:
        legend_text.append("\n")
        legend_text.append(_sort_note)
    if max_depth > 0:
        level_word = "level" if max_depth == 1 else "levels"
        legend_text.append("\n")
        legend_text.append(f"Directory tree is limited to {max_depth} {level_word}")
    if show_full_path:
        legend_text.append("\n")
        legend_text.append("Full file paths are shown instead of just filenames")
    if exclude_patterns or include_patterns:
        pattern_info = []
        if exclude_patterns:
            pattern_type = "Regex" if use_regex else "Glob"
            pattern_info.append(
                f"{pattern_type} exclusion patterns: {', '.join(str(p) for p in exclude_patterns)}"
            )
        if include_patterns:
            pattern_type = "Regex" if use_regex else "Glob"
            pattern_info.append(
                f"{pattern_type} inclusion patterns: {', '.join(str(p) for p in include_patterns)}"
            )
        if pattern_info:
            pattern_panel = Panel(
                "\n".join(pattern_info), title="Applied Patterns", border_style="blue"
            )
            console.print(pattern_panel)
    legend_panel = Panel(legend_text, border_style="dim")
    console.print(legend_panel)
    console.print(
        _render_side_by_side(
            console,
            Panel(
                tree1,
                title=f"Directory 1: {root_base1}",
                border_style="blue",
            ),
            Panel(
                tree2,
                title=f"Directory 2: {root_base2}",
                border_style="green",
            ),
        )
    )

export_comparison(dir1, dir2, format_type, output_path, exclude_dirs=None, ignore_file=None, exclude_extensions=None, exclude_patterns=None, include_patterns=None, use_regex=False, max_depth=0, show_full_path=False, spec=None, icon_style='emoji')

Export a side-by-side directory comparison to an HTML file.

Scans both directories with identical options and writes a standalone, responsive HTML document containing the highlighted comparison, a legend, and a summary of the settings used. Only HTML output is supported.

Parameters:

Name Type Description Default
dir1 str

Path to the first directory.

required
dir2 str

Path to the second directory.

required
format_type str

Export format. Only "html" is supported.

required
output_path str

Path the HTML file is written to.

required
exclude_dirs list[str] | None

Directory names to skip entirely.

None
ignore_file str | None

Name of an ignore file to honor (e.g. .gitignore).

None
exclude_extensions set[str] | None

File extensions to exclude. Normalized to a lowercase, dot-prefixed form before scanning.

None
exclude_patterns list[str] | None

Glob or regex patterns to exclude.

None
include_patterns list[str] | None

Glob or regex patterns to include, which override the exclusions.

None
use_regex bool

Whether to treat the patterns as regular expressions instead of glob patterns.

False
max_depth int

Maximum depth to include, or 0 for unlimited.

0
show_full_path bool

Whether to write absolute paths instead of bare filenames.

False
spec DisplayOptions | None

Resolved sorting and annotation directives. Defaults to a plain :class:DisplayOptions.

None
icon_style str

Icon style to use, either "emoji" or "nerd".

'emoji'

Raises:

Type Description
ValueError

If format_type is not "html".

Source code in recursivist/compare.py
def export_comparison(
    dir1: str,
    dir2: str,
    format_type: str,
    output_path: str,
    exclude_dirs: list[str] | None = None,
    ignore_file: str | None = None,
    exclude_extensions: set[str] | None = None,
    exclude_patterns: list[str] | None = None,
    include_patterns: list[str] | None = None,
    use_regex: bool = False,
    max_depth: int = 0,
    show_full_path: bool = False,
    spec: DisplayOptions | None = None,
    icon_style: str = "emoji",
) -> None:
    """Export a side-by-side directory comparison to an HTML file.

    Scans both directories with identical options and writes a standalone,
    responsive HTML document containing the highlighted comparison, a legend,
    and a summary of the settings used. Only HTML output is supported.

    Args:
        dir1: Path to the first directory.
        dir2: Path to the second directory.
        format_type: Export format. Only ``"html"`` is supported.
        output_path: Path the HTML file is written to.
        exclude_dirs: Directory names to skip entirely.
        ignore_file: Name of an ignore file to honor (e.g. ``.gitignore``).
        exclude_extensions: File extensions to exclude. Normalized to a
            lowercase, dot-prefixed form before scanning.
        exclude_patterns: Glob or regex patterns to exclude.
        include_patterns: Glob or regex patterns to include, which override
            the exclusions.
        use_regex: Whether to treat the patterns as regular expressions
            instead of glob patterns.
        max_depth: Maximum depth to include, or ``0`` for unlimited.
        show_full_path: Whether to write absolute paths instead of bare
            filenames.
        spec: Resolved sorting and annotation directives. Defaults to a plain
            :class:`DisplayOptions`.
        icon_style: Icon style to use, either ``"emoji"`` or ``"nerd"``.

    Raises:
        ValueError: If *format_type* is not ``"html"``.
    """
    if format_type != "html":
        raise ValueError("Only HTML format is supported for comparison export")
    if spec is None:
        spec = DisplayOptions()
    if exclude_dirs is None:
        exclude_dirs = []
    if exclude_extensions is None:
        exclude_extensions = set()
    if exclude_patterns is None:
        exclude_patterns = []
    if include_patterns is None:
        include_patterns = []
    exclude_extensions = {
        ext.lower() if ext.startswith(".") else f".{ext.lower()}"
        for ext in exclude_extensions
    }
    compiled_exclude = compile_regex_patterns(exclude_patterns, use_regex)
    compiled_include = compile_regex_patterns(include_patterns, use_regex)
    structure1, structure2 = compare_directory_structures(
        dir1,
        dir2,
        exclude_dirs,
        ignore_file,
        exclude_extensions,
        exclude_patterns=compiled_exclude,
        include_patterns=compiled_include,
        max_depth=max_depth,
        show_full_path=show_full_path,
        spec=spec,
    )
    identity_spec = _identity_spec_for(dir1, dir2, spec)

    is_remote1 = parse_github_url(dir1) is not None
    is_remote2 = parse_github_url(dir2) is not None

    comparison_data = {
        "dir1": {
            "path": dir1,
            "name": _side_display_name(dir1),
            "structure": structure1,
            "is_remote": is_remote1,
        },
        "dir2": {
            "path": dir2,
            "name": _side_display_name(dir2),
            "structure": structure2,
            "is_remote": is_remote2,
        },
        "metadata": {
            "exclude_patterns": [str(p) for p in exclude_patterns],
            "include_patterns": [str(p) for p in include_patterns],
            "pattern_type": "regex" if use_regex else "glob",
            "max_depth": max_depth,
            "show_full_path": show_full_path,
            "metrics": list(spec.metrics),
            "sort_key": spec.sort_key,
            "show_loc": spec.show_loc,
            "show_size": spec.show_size,
            "show_mtime": spec.show_mtime,
            "show_git_status": spec.show_git_status,
            "identity_metrics": list(identity_spec.metrics),
            "identity_git": identity_spec.show_git_status,
        },
    }
    _export_comparison_to_html(comparison_data, output_path, icon_style)

Filtering

recursivist.filtering

File and directory filtering: ignore files, glob/regex patterns, and gitignore-style exclusion rules.

Provides the predicate :func:should_exclude used by the scanner. Git-style ignore matching is delegated to :mod:pathspec (its gitignore matcher), which implements the full gitignore specification: anchoring, ** wildcards, directory-only (trailing /) patterns, ! negation with last-match-wins, character classes, backslash escapes, and trailing-whitespace handling. The glob and regex matching used by --exclude-pattern/--include-pattern is unrelated and remains pure standard library.

Like Git, each ignore file is evaluated relative to the directory that contains it rather than relative to the scan root. The active ignore files are kept as a stack (shallowest first); a path is tested against every level with its own anchoring, and a deeper file's verdict overrides a shallower one, so an anchored pattern such as /build in a nested .gitignore matches only within that subdirectory and does not leak up to the scan root or down past the anchor.

compile_regex_patterns(patterns, is_regex=False)

Compile patterns to regex objects when regex matching is requested.

When is_regex is False the patterns are returned unchanged for glob matching. When True each pattern is compiled to a :class:re.Pattern; any pattern that fails to compile is kept as a string and a warning is logged.

Parameters:

Name Type Description Default
patterns Sequence[str]

Patterns to process.

required
is_regex bool

Whether to treat the patterns as regular expressions (True) or glob patterns (False).

False

Returns:

Type Description
list[str | Pattern[str]]

A list whose items are plain strings for glob patterns or compiled

list[str | Pattern[str]]

class:re.Pattern objects for successfully compiled regexes.

Source code in recursivist/filtering.py
def compile_regex_patterns(
    patterns: Sequence[str], is_regex: bool = False
) -> list[str | Pattern[str]]:
    """Compile patterns to regex objects when regex matching is requested.

    When *is_regex* is ``False`` the patterns are returned unchanged for glob
    matching. When ``True`` each pattern is compiled to a
    :class:`re.Pattern`; any pattern that fails to compile is kept as a string
    and a warning is logged.

    Args:
        patterns: Patterns to process.
        is_regex: Whether to treat the patterns as regular expressions
            (``True``) or glob patterns (``False``).

    Returns:
        A list whose items are plain strings for glob patterns or compiled
        :class:`re.Pattern` objects for successfully compiled regexes.
    """
    if not is_regex:
        return cast(list[str | Pattern[str]], patterns)
    compiled_patterns: list[str | Pattern[str]] = []
    for pattern in patterns:
        try:
            compiled_patterns.append(re.compile(pattern))
        except re.error as e:
            logger.warning(f"Invalid regex pattern '{pattern}': {e}")
            compiled_patterns.append(pattern)
    return compiled_patterns

parse_ignore_file(ignore_file_path)

Read an ignore file and return its lines as gitignore patterns.

Lines are returned verbatim with only their terminators removed, preserving order and every character that is significant to the gitignore grammar (comments, blank lines, backslash escapes, and escaped trailing whitespace). Interpretation is left entirely to the gitignore matcher, so callers must not strip or filter the returned lines.

Parameters:

Name Type Description Default
ignore_file_path str

Path to the ignore file (e.g. .gitignore).

required

Returns:

Type Description
list[str]

The list of pattern lines, or an empty list when the file does not

list[str]

exist.

Source code in recursivist/filtering.py
def parse_ignore_file(ignore_file_path: str) -> list[str]:
    """Read an ignore file and return its lines as gitignore patterns.

    Lines are returned verbatim with only their terminators removed, preserving
    order and every character that is significant to the gitignore grammar
    (comments, blank lines, backslash escapes, and escaped trailing
    whitespace). Interpretation is left entirely to the gitignore matcher, so
    callers must not strip or filter the returned lines.

    Args:
        ignore_file_path: Path to the ignore file (e.g. ``.gitignore``).

    Returns:
        The list of pattern lines, or an empty list when the file does not
        exist.
    """
    if not os.path.exists(ignore_file_path):
        return []
    with open(ignore_file_path, encoding="utf-8", errors="replace") as f:
        return f.read().splitlines()

should_exclude(path, ignore_context, exclude_extensions=None, exclude_patterns=None, include_patterns=None)

Decide whether a path should be excluded from the scan.

The filtering rules are applied in priority order:

  1. If include_patterns are given and none match a file, exclude it.
  2. If any exclude_patterns match, exclude the path (this overrides include patterns).
  3. If the file's extension is in exclude_extensions, exclude it.
  4. If an include pattern matched, include the path (this overrides the gitignore-style patterns below).
  5. Otherwise apply the gitignore-style rules from ignore_context via :mod:pathspec, honoring anchoring, ** wildcards, directory-only (trailing /) patterns, ! negation, character classes and escapes per the gitignore specification. Each ignore file in the stack is matched relative to its own directory and deeper files override shallower ones, so a nested file's anchored patterns stay scoped to its subtree.

Parameters:

Name Type Description Default
path str

Filesystem path to test.

required
ignore_context dict[str, Any]

Mapping describing the active ignore rules. Recognized keys are "pattern_stack" (a shallowest-first sequence of (base_dir_relative_to_root, patterns) pairs, one per ignore file), "patterns" (a legacy flat pattern list, treated as a single ignore file at the scan root when "pattern_stack" is absent), "current_dir" (directory used to anchor the --exclude-pattern/--include-pattern globs), and "rel_dir" (the current directory's path relative to the scan root).

required
exclude_extensions set[str] | None

Lowercase, dot-prefixed extensions to exclude.

None
exclude_patterns Sequence[str | Pattern[str]] | None

Glob or compiled-regex patterns to exclude.

None
include_patterns Sequence[str | Pattern[str]] | None

Glob or compiled-regex patterns to include, which override the gitignore-style exclusions.

None

Returns:

Type Description
bool

True if the path should be excluded, False otherwise.

Source code in recursivist/filtering.py
def should_exclude(
    path: str,
    ignore_context: dict[str, Any],
    exclude_extensions: set[str] | None = None,
    exclude_patterns: Sequence[str | Pattern[str]] | None = None,
    include_patterns: Sequence[str | Pattern[str]] | None = None,
) -> bool:
    """Decide whether a path should be excluded from the scan.

    The filtering rules are applied in priority order:

    1. If *include_patterns* are given and none match a file, exclude it.
    2. If any *exclude_patterns* match, exclude the path (this overrides
       include patterns).
    3. If the file's extension is in *exclude_extensions*, exclude it.
    4. If an include pattern matched, include the path (this overrides the
       gitignore-style patterns below).
    5. Otherwise apply the gitignore-style rules from *ignore_context* via
       :mod:`pathspec`, honoring anchoring, ``**`` wildcards, directory-only
       (trailing ``/``) patterns, ``!`` negation, character classes and escapes
       per the gitignore specification. Each ignore file in the stack is matched
       relative to its own directory and deeper files override shallower ones,
       so a nested file's anchored patterns stay scoped to its subtree.

    Args:
        path: Filesystem path to test.
        ignore_context: Mapping describing the active ignore rules. Recognized
            keys are ``"pattern_stack"`` (a shallowest-first sequence of
            ``(base_dir_relative_to_root, patterns)`` pairs, one per ignore
            file), ``"patterns"`` (a legacy flat pattern list, treated as a
            single ignore file at the scan root when ``"pattern_stack"`` is
            absent), ``"current_dir"`` (directory used to anchor the
            ``--exclude-pattern``/``--include-pattern`` globs), and ``"rel_dir"``
            (the current directory's path relative to the scan root).
        exclude_extensions: Lowercase, dot-prefixed extensions to exclude.
        exclude_patterns: Glob or compiled-regex patterns to exclude.
        include_patterns: Glob or compiled-regex patterns to include, which
            override the gitignore-style exclusions.

    Returns:
        ``True`` if the path should be excluded, ``False`` otherwise.
    """
    current_dir = ignore_context.get("current_dir", os.path.dirname(path))
    rel_path = os.path.relpath(path, current_dir)
    if os.name == "nt":
        rel_path = rel_path.replace("\\", "/")
    basename = os.path.basename(path)
    if include_patterns and not os.path.isdir(path):
        included = False
        for pattern in include_patterns:
            if isinstance(pattern, Pattern):
                if pattern.search(rel_path) or pattern.search(basename):
                    included = True
                    break
            else:
                if fnmatch.fnmatch(rel_path, pattern) or fnmatch.fnmatch(
                    basename, pattern
                ):
                    included = True
                    break
        if not included:
            return True
    if exclude_patterns:
        for pattern in exclude_patterns:
            if isinstance(pattern, Pattern):
                if pattern.search(rel_path) or pattern.search(basename):
                    return True
            else:
                if fnmatch.fnmatch(rel_path, pattern) or fnmatch.fnmatch(
                    basename, pattern
                ):
                    return True
    if exclude_extensions and os.path.isfile(path):
        _, ext = os.path.splitext(path)
        if ext.lower() in exclude_extensions:
            return True
    if include_patterns:
        return False
    levels = _resolve_ignore_levels(ignore_context)
    if not levels:
        return False
    rel_dir = ignore_context.get("rel_dir", "")
    target = (f"{rel_dir}/{basename}" if rel_dir else basename).replace("\\", "/")
    target = target.lstrip("/")
    if os.path.isdir(path):
        target += "/"
    return _is_ignored_by_stack(target, levels)

Flags

recursivist.flags

Command-line flag resolution for file sorting and annotation.

Recursivist exposes three families of file-annotation flags:

  • Sorting-only--sort-by-similarity groups similarly named files together but adds no annotation of its own.
  • Combined--sort-by-loc, --sort-by-size, --sort-by-mtime and --sort-by-git-status each sort files by a metric and annotate every file with that metric.
  • Display-only--loc, --size, --mtime and --git-status annotate files with a metric without influencing the ordering.

When several of these flags are combined, they are resolved strictly by their left-to-right order on the command line rather than by any fixed internal precedence. The rules, implemented by :func:resolve_flags, are:

  • Only the first sorting flag (sorting-only or combined) is honored; every later sorting flag is discarded completely — it contributes neither ordering nor annotation.
  • Display-only flags always annotate. Their annotations appear in the exact order the flags were given.
  • When the winning sort is a combined numeric metric (LOC, size or mtime), that metric's annotation is shown first, ahead of any display-only ones.
  • When the winning sort is a combined Git-status flag, its badge trails at the very end, after every display-only annotation.

The resolution is expressed as a :class:DisplayOptions, the single value the renderers and exporters consult to decide how to sort and what to annotate.

DisplayOptions dataclass

Resolved sorting and annotation directives for a single run.

This is the value produced by :func:resolve_flags and threaded through the renderers and exporters. It cleanly separates the two concerns the flags used to conflate: how files are ordered (:attr:sort_key) and what is annotated, and in what order (:attr:metrics plus :attr:show_git_status).

Attributes:

Name Type Description
sort_key str | None

The single metric files are ordered by — one of :data:METRIC_LOC, :data:METRIC_SIZE, :data:METRIC_MTIME, :data:METRIC_GIT, :data:METRIC_SIMILARITY, or None to keep the default extension/name ordering.

metrics tuple[str, ...]

The numeric metrics (subset of :data:NUMERIC_METRICS) to annotate files with, in the exact order they should be displayed.

show_git_status bool

Whether to append the Git-status badge to each file. The badge always trails the numeric-metric parenthetical.

Source code in recursivist/flags.py
@dataclass(frozen=True)
class DisplayOptions:
    """Resolved sorting and annotation directives for a single run.

    This is the value produced by :func:`resolve_flags` and threaded through the
    renderers and exporters. It cleanly separates the two concerns the flags
    used to conflate: *how files are ordered* (:attr:`sort_key`) and *what is
    annotated, and in what order* (:attr:`metrics` plus :attr:`show_git_status`).

    Attributes:
        sort_key: The single metric files are ordered by — one of
            :data:`METRIC_LOC`, :data:`METRIC_SIZE`, :data:`METRIC_MTIME`,
            :data:`METRIC_GIT`, :data:`METRIC_SIMILARITY`, or ``None`` to keep
            the default extension/name ordering.
        metrics: The numeric metrics (subset of :data:`NUMERIC_METRICS`) to
            annotate files with, in the exact order they should be displayed.
        show_git_status: Whether to append the Git-status badge to each file.
            The badge always trails the numeric-metric parenthetical.
    """

    sort_key: str | None = None
    metrics: tuple[str, ...] = ()
    show_git_status: bool = False

    @property
    def show_loc(self) -> bool:
        """Whether the lines-of-code annotation is shown."""
        return METRIC_LOC in self.metrics

    @property
    def show_size(self) -> bool:
        """Whether the file-size annotation is shown."""
        return METRIC_SIZE in self.metrics

    @property
    def show_mtime(self) -> bool:
        """Whether the modification-time annotation is shown."""
        return METRIC_MTIME in self.metrics

    @property
    def sorts_by_metric(self) -> bool:
        """Whether ordering is driven by a numeric metric (LOC, size, mtime)."""
        return self.sort_key in NUMERIC_METRICS

    def without_remote_unsupported(self) -> "DisplayOptions":
        """Return a copy with annotations that don't apply to a hosted repo.

        A GitHub checkout has no meaningful per-file Git status or modification
        time — every file effectively shares the tip commit's status and
        timestamp — so the Git-status badge and the modification-time metric are
        dropped, and a sort keyed on either falls back to the default ordering.
        The lines-of-code and size metrics are retained, since those are
        computed from the file contents themselves.

        Returns:
            A :class:`DisplayOptions` with Git status and modification time
            removed from both the sort key and the annotation set.
        """
        sort_key = self.sort_key
        if sort_key in (METRIC_GIT, METRIC_MTIME):
            sort_key = None
        metrics = tuple(metric for metric in self.metrics if metric != METRIC_MTIME)
        return DisplayOptions(
            sort_key=sort_key,
            metrics=metrics,
            show_git_status=False,
        )

show_loc property

Whether the lines-of-code annotation is shown.

show_mtime property

Whether the modification-time annotation is shown.

show_size property

Whether the file-size annotation is shown.

sorts_by_metric property

Whether ordering is driven by a numeric metric (LOC, size, mtime).

without_remote_unsupported()

Return a copy with annotations that don't apply to a hosted repo.

A GitHub checkout has no meaningful per-file Git status or modification time — every file effectively shares the tip commit's status and timestamp — so the Git-status badge and the modification-time metric are dropped, and a sort keyed on either falls back to the default ordering. The lines-of-code and size metrics are retained, since those are computed from the file contents themselves.

Returns:

Name Type Description
A DisplayOptions

class:DisplayOptions with Git status and modification time

DisplayOptions

removed from both the sort key and the annotation set.

Source code in recursivist/flags.py
def without_remote_unsupported(self) -> "DisplayOptions":
    """Return a copy with annotations that don't apply to a hosted repo.

    A GitHub checkout has no meaningful per-file Git status or modification
    time — every file effectively shares the tip commit's status and
    timestamp — so the Git-status badge and the modification-time metric are
    dropped, and a sort keyed on either falls back to the default ordering.
    The lines-of-code and size metrics are retained, since those are
    computed from the file contents themselves.

    Returns:
        A :class:`DisplayOptions` with Git status and modification time
        removed from both the sort key and the annotation set.
    """
    sort_key = self.sort_key
    if sort_key in (METRIC_GIT, METRIC_MTIME):
        sort_key = None
    metrics = tuple(metric for metric in self.metrics if metric != METRIC_MTIME)
    return DisplayOptions(
        sort_key=sort_key,
        metrics=metrics,
        show_git_status=False,
    )

FlagSpec dataclass

Static description of a single order-sensitive flag.

Attributes:

Name Type Description
id str

Stable identifier used as a dictionary key by the CLI layer.

mode str

One of :data:MODE_SORT_ONLY, :data:MODE_COMBINED, or :data:MODE_DISPLAY_ONLY.

metric str

The metric the flag relates to (e.g. :data:METRIC_LOC).

long str

The long option string, including the leading dashes.

short str | None

The single-letter short option (without the dash), or None when the flag has no short form.

Source code in recursivist/flags.py
@dataclass(frozen=True)
class FlagSpec:
    """Static description of a single order-sensitive flag.

    Attributes:
        id: Stable identifier used as a dictionary key by the CLI layer.
        mode: One of :data:`MODE_SORT_ONLY`, :data:`MODE_COMBINED`, or
            :data:`MODE_DISPLAY_ONLY`.
        metric: The metric the flag relates to (e.g. :data:`METRIC_LOC`).
        long: The long option string, including the leading dashes.
        short: The single-letter short option (without the dash), or ``None``
            when the flag has no short form.
    """

    id: str
    mode: str
    metric: str
    long: str
    short: str | None = None

resolve_display_options(*, sort_loc=False, sort_size=False, sort_mtime=False, sort_similarity=False, sort_git=False, disp_loc=False, disp_size=False, disp_mtime=False, disp_git=False, tokens=None)

Resolve the raw per-flag booleans into :class:DisplayOptions.

The set of active flags comes from the boolean arguments (which the CLI parser has already validated), while their relative order is recovered from tokens — the raw command-line arguments. This split keeps resolution robust: the parser is the source of truth for which flags are present, and the token scan only orders that known set. A flag that cannot be located in tokens falls back to its registry position so ordering stays deterministic.

Parameters:

Name Type Description Default
sort_loc bool

Whether --sort-by-loc was given.

False
sort_size bool

Whether --sort-by-size was given.

False
sort_mtime bool

Whether --sort-by-mtime was given.

False
sort_similarity bool

Whether --sort-by-similarity was given.

False
sort_git bool

Whether --sort-by-git-status was given.

False
disp_loc bool

Whether --loc was given.

False
disp_size bool

Whether --size was given.

False
disp_mtime bool

Whether --mtime was given.

False
disp_git bool

Whether --git-status was given.

False
tokens Sequence[str] | None

The raw command-line tokens used to order the active flags. Defaults to sys.argv[1:].

None

Returns:

Type Description
DisplayOptions

The resolved :class:DisplayOptions.

Source code in recursivist/flags.py
def resolve_display_options(
    *,
    sort_loc: bool = False,
    sort_size: bool = False,
    sort_mtime: bool = False,
    sort_similarity: bool = False,
    sort_git: bool = False,
    disp_loc: bool = False,
    disp_size: bool = False,
    disp_mtime: bool = False,
    disp_git: bool = False,
    tokens: Sequence[str] | None = None,
) -> DisplayOptions:
    """Resolve the raw per-flag booleans into :class:`DisplayOptions`.

    The set of active flags comes from the boolean arguments (which the CLI
    parser has already validated), while their relative order is recovered from
    *tokens* — the raw command-line arguments. This split keeps resolution
    robust: the parser is the source of truth for *which* flags are present, and
    the token scan only orders that known set. A flag that cannot be located in
    *tokens* falls back to its registry position so ordering stays deterministic.

    Args:
        sort_loc: Whether ``--sort-by-loc`` was given.
        sort_size: Whether ``--sort-by-size`` was given.
        sort_mtime: Whether ``--sort-by-mtime`` was given.
        sort_similarity: Whether ``--sort-by-similarity`` was given.
        sort_git: Whether ``--sort-by-git-status`` was given.
        disp_loc: Whether ``--loc`` was given.
        disp_size: Whether ``--size`` was given.
        disp_mtime: Whether ``--mtime`` was given.
        disp_git: Whether ``--git-status`` was given.
        tokens: The raw command-line tokens used to order the active flags.
            Defaults to ``sys.argv[1:]``.

    Returns:
        The resolved :class:`DisplayOptions`.
    """
    if tokens is None:
        tokens = sys.argv[1:]

    active = {
        "sort_similarity": sort_similarity,
        "sort_loc": sort_loc,
        "sort_size": sort_size,
        "sort_mtime": sort_mtime,
        "sort_git": sort_git,
        "disp_loc": disp_loc,
        "disp_size": disp_size,
        "disp_mtime": disp_mtime,
        "disp_git": disp_git,
    }
    active_specs = [_SPEC_BY_ID[flag_id] for flag_id, on in active.items() if on]

    def sort_key(spec: FlagSpec) -> tuple[int, int]:
        position = _cli_order_key(spec, tokens)
        if position is None:
            return (len(tokens) + 1, _REGISTRY_INDEX[spec.id])
        return position

    ordered = sorted(active_specs, key=sort_key)
    events = [(spec.mode, spec.metric) for spec in ordered]
    return resolve_flags(events)

resolve_flags(events)

Resolve an ordered sequence of flag events into :class:DisplayOptions.

Each event is a (mode, metric) pair drawn from the registry, in the left-to-right order the flags appeared on the command line. The resolution rules are described in the module docstring.

Parameters:

Name Type Description Default
events Sequence[tuple[str, str]]

The flag events, ordered by command-line position.

required

Returns:

Type Description
DisplayOptions

The resolved :class:DisplayOptions.

Source code in recursivist/flags.py
def resolve_flags(events: Sequence[tuple[str, str]]) -> DisplayOptions:
    """Resolve an ordered sequence of flag events into :class:`DisplayOptions`.

    Each event is a ``(mode, metric)`` pair drawn from the registry, in the
    left-to-right order the flags appeared on the command line. The resolution
    rules are described in the module docstring.

    Args:
        events: The flag events, ordered by command-line position.

    Returns:
        The resolved :class:`DisplayOptions`.
    """
    sort_key: str | None = None
    sort_locked = False
    combined_winner: str | None = None
    display_only_metrics: list[str] = []
    display_only_git = False

    for mode, metric in events:
        if mode in (MODE_SORT_ONLY, MODE_COMBINED):
            if not sort_locked:
                sort_locked = True
                sort_key = metric
                if mode == MODE_COMBINED:
                    combined_winner = metric
        elif metric == METRIC_GIT:
            display_only_git = True
        elif metric not in display_only_metrics:
            display_only_metrics.append(metric)

    metrics: list[str] = []
    if combined_winner in NUMERIC_METRICS:
        metrics.append(combined_winner)
    for metric in display_only_metrics:
        if metric not in metrics:
            metrics.append(metric)

    show_git = display_only_git or combined_winner == METRIC_GIT
    return DisplayOptions(
        sort_key=sort_key,
        metrics=tuple(metrics),
        show_git_status=show_git,
    )

Sorting

recursivist.sorting

File ordering.

Sorts a directory's files by extension/name, by a numeric metric (LOC, size, mtime), or groups them by name similarity. Operates on :class:FileEntry.

sort_files_by_similarity(files)

Order files so that similarly named files sit next to each other.

Unlike the LOC/size/mtime metrics, name similarity is relational: it depends on how a file's name compares to the others rather than on any single measured value, so it cannot be expressed as a sorted key. Instead this builds a greedy nearest-neighbour chain:

  1. Entries are seeded in case-insensitive name order and the alphabetically-first name becomes the start of the chain. Using a fixed, name-derived anchor makes the result deterministic and stable across runs (the same directory always yields the same order).
  2. Repeatedly, the not-yet-placed entry whose name is most similar to the most recently placed name is appended. Similarity is the :class:difflib.SequenceMatcher ratio computed case-insensitively on the full filename (extension included), so main.py/main.js and test_api.py/test_api.js naturally cluster.
  3. Ratio ties are broken by case-insensitive name order: because the candidates are kept alphabetically sorted and the best match is only replaced on a strictly greater ratio, the alphabetically-first of any tied group wins.

This is a heuristic (locally greedy) ordering rather than a globally optimal grouping, which is the appropriate trade-off for a directory listing: each directory holds relatively few files, so the O(n^2) pairwise comparisons are cheap, and the result reliably places obvious name-siblings adjacent to one another.

Inputs may be :class:FileEntry instances, bare filename strings, or positional tuples; every item is normalised to a :class:FileEntry via :meth:FileEntry.coerce before ordering. Since only the name is used, the metric slots do not affect the result.

Parameters:

Name Type Description Default
files Sequence[Any]

List of file items (FileEntry, tuple, or str).

required

Returns:

Type Description
list[FileEntry]

Reordered list of :class:FileEntry with name-similar files adjacent.

Source code in recursivist/sorting.py
def sort_files_by_similarity(files: Sequence[Any]) -> list[FileEntry]:
    """Order files so that similarly named files sit next to each other.

    Unlike the LOC/size/mtime metrics, name similarity is *relational*: it
    depends on how a file's name compares to the others rather than on any
    single measured value, so it cannot be expressed as a ``sorted`` key.
    Instead this builds a greedy nearest-neighbour chain:

    1. Entries are seeded in case-insensitive name order and the
       alphabetically-first name becomes the start of the chain. Using a
       fixed, name-derived anchor makes the result deterministic and stable
       across runs (the same directory always yields the same order).
    2. Repeatedly, the not-yet-placed entry whose name is most similar to the
       most recently placed name is appended. Similarity is the
       :class:`difflib.SequenceMatcher` ratio computed case-insensitively on
       the full filename (extension included), so ``main.py``/``main.js`` and
       ``test_api.py``/``test_api.js`` naturally cluster.
    3. Ratio ties are broken by case-insensitive name order: because the
       candidates are kept alphabetically sorted and the best match is only
       replaced on a strictly greater ratio, the alphabetically-first of any
       tied group wins.

    This is a heuristic (locally greedy) ordering rather than a globally
    optimal grouping, which is the appropriate trade-off for a directory
    listing: each directory holds relatively few files, so the ``O(n^2)``
    pairwise comparisons are cheap, and the result reliably places obvious
    name-siblings adjacent to one another.

    Inputs may be :class:`FileEntry` instances, bare filename strings, or
    positional tuples; every item is normalised to a :class:`FileEntry` via
    :meth:`FileEntry.coerce` before ordering. Since only the name is used, the
    metric slots do not affect the result.

    Args:
        files: List of file items (``FileEntry``, tuple, or ``str``).

    Returns:
        Reordered list of :class:`FileEntry` with name-similar files adjacent.
    """
    if not files:
        return []
    entries = [FileEntry.coerce(f) for f in files]
    if len(entries) < 2:
        return entries
    remaining = sorted(entries, key=lambda e: e.name.lower())
    ordered: list[FileEntry] = [remaining.pop(0)]
    matcher = SequenceMatcher(autojunk=False)
    while remaining:
        matcher.set_seq2(ordered[-1].name.lower())
        best_idx = 0
        best_ratio = -1.0
        for idx, candidate in enumerate(remaining):
            matcher.set_seq1(candidate.name.lower())
            ratio = matcher.ratio()
            if ratio > best_ratio:
                best_ratio = ratio
                best_idx = idx
        ordered.append(remaining.pop(best_idx))
    return ordered

sort_files_by_type(files, sort_key=None, git_markers=None)

Order files by a single resolved sort key.

Exactly one ordering is applied, chosen by sort_key:

  • None: the default — by extension, then case-insensitive name.
  • "loc" / "size" / "mtime": by that metric, largest/newest first (a stable sort keeps the pre-existing order for equal values).
  • "git_status": grouped by Git status (modified, added, deleted, untracked, then clean), and by case-insensitive name within each group. Requires git_markers.
  • "similarity": by name similarity, via :func:sort_files_by_similarity.

This mirrors the resolution in :mod:recursivist.flags, where only the first sorting flag on the command line takes effect, so there is never more than one active metric to combine.

Inputs may be :class:FileEntry instances, bare filename strings, or positional tuples; every item is normalised to a :class:FileEntry via :meth:FileEntry.coerce before sorting.

Parameters:

Name Type Description Default
files Sequence[Any]

List of file items (FileEntry, tuple, or str).

required
sort_key str | None

The single metric to order by, or None for the default.

None
git_markers Mapping[str, str] | None

{filename: status_char} mapping, required when sort_key is "git_status".

None

Returns:

Type Description
list[FileEntry]

Sorted list of :class:FileEntry.

Source code in recursivist/sorting.py
def sort_files_by_type(
    files: Sequence[Any],
    sort_key: str | None = None,
    git_markers: Mapping[str, str] | None = None,
) -> list[FileEntry]:
    """Order files by a single resolved sort key.

    Exactly one ordering is applied, chosen by *sort_key*:

    - ``None``: the default — by extension, then case-insensitive name.
    - ``"loc"`` / ``"size"`` / ``"mtime"``: by that metric, largest/newest
      first (a stable sort keeps the pre-existing order for equal values).
    - ``"git_status"``: grouped by Git status (modified, added, deleted,
      untracked, then clean), and by case-insensitive name within each group.
      Requires *git_markers*.
    - ``"similarity"``: by name similarity, via
      :func:`sort_files_by_similarity`.

    This mirrors the resolution in :mod:`recursivist.flags`, where only the
    first sorting flag on the command line takes effect, so there is never more
    than one active metric to combine.

    Inputs may be :class:`FileEntry` instances, bare filename strings, or
    positional tuples; every item is normalised to a :class:`FileEntry` via
    :meth:`FileEntry.coerce` before sorting.

    Args:
        files: List of file items (``FileEntry``, tuple, or ``str``).
        sort_key: The single metric to order by, or ``None`` for the default.
        git_markers: ``{filename: status_char}`` mapping, required when
            *sort_key* is ``"git_status"``.

    Returns:
        Sorted list of :class:`FileEntry`.
    """
    if not files:
        return []

    entries = [FileEntry.coerce(f) for f in files]

    if sort_key == METRIC_LOC:
        return sorted(entries, key=lambda e: -e.loc)
    if sort_key == METRIC_SIZE:
        return sorted(entries, key=lambda e: -e.size)
    if sort_key == METRIC_MTIME:
        return sorted(entries, key=lambda e: -e.mtime)
    if sort_key == METRIC_GIT:
        markers = git_markers or {}
        return sorted(
            entries,
            key=lambda e: (
                _GIT_SORT_RANK.get(markers.get(e.name, ""), _GIT_SORT_CLEAN),
                e.name.lower(),
            ),
        )
    if sort_key == METRIC_SIMILARITY:
        return sort_files_by_similarity(entries)
    return sorted(
        entries,
        key=lambda e: (os.path.splitext(e.name)[1].lower(), e.name.lower()),
    )

Metrics

recursivist.metrics

File statistics and metric formatting.

Lines-of-code counting, file size and modification-time retrieval, and the helpers that format those metrics into the annotation suffixes shown next to files and directories. Pure standard library.

count_lines_of_code(file_path)

Count the number of lines in a text file.

Detects the encoding well enough to count lines reliably: UTF-16 files are recognized by their byte-order mark or by a regular pattern of null bytes, while files containing null bytes that are not UTF-16 are treated as binary and skipped. Decoding falls back from strict UTF-8 to UTF-16 and finally to UTF-8 with replacement so that text is never rejected over a stray byte.

Parameters:

Name Type Description Default
file_path str

Path to the file.

required

Returns:

Type Description
int

The number of lines, or 0 if the file is empty, binary, or cannot

int

be read.

Source code in recursivist/metrics.py
def count_lines_of_code(file_path: str) -> int:
    """Count the number of lines in a text file.

    Detects the encoding well enough to count lines reliably: UTF-16 files are
    recognized by their byte-order mark or by a regular pattern of null bytes,
    while files containing null bytes that are not UTF-16 are treated as binary
    and skipped. Decoding falls back from strict UTF-8 to UTF-16 and finally to
    UTF-8 with replacement so that text is never rejected over a stray byte.

    Args:
        file_path: Path to the file.

    Returns:
        The number of lines, or ``0`` if the file is empty, binary, or cannot
        be read.
    """
    try:
        with open(file_path, "rb") as binary_file:
            sample = binary_file.read(4096)
            if not sample:
                return 0
            utf16_le_bom = sample.startswith(b"\xff\xfe")
            utf16_be_bom = sample.startswith(b"\xfe\xff")
            if utf16_le_bom or utf16_be_bom:
                encoding = "utf-16-le" if utf16_le_bom else "utf-16-be"
                with open(file_path, encoding=encoding, errors="replace") as text_file:
                    return sum(1 for _ in text_file)
            potential_utf16le: bool = False
            potential_utf16be: bool = False
            if len(sample) >= 16:
                potential_utf16le = all(
                    sample[i] == 0 for i in range(1, min(32, len(sample)), 2)
                )
                potential_utf16be = all(
                    sample[i] == 0 for i in range(0, min(32, len(sample)), 2)
                )
                if potential_utf16le or potential_utf16be:
                    encoding = "utf-16-le" if potential_utf16le else "utf-16-be"
                    try:
                        with open(
                            file_path, encoding=encoding, errors="replace"
                        ) as text_file:
                            return sum(1 for _ in text_file)
                    except Exception:
                        pass
            if b"\x00" in sample and not (potential_utf16le or potential_utf16be):
                return 0
    except Exception as e:
        logger.debug(f"Could not analyze file: {file_path}: {e}")
        return 0
    try:
        with open(file_path, encoding="utf-8", errors="strict") as text_file:
            return sum(1 for _ in text_file)
    except UnicodeDecodeError:
        pass
    except Exception as e:
        logger.debug(f"Could not read file as UTF-8: {file_path}: {e}")
        return 0

    try:
        with open(file_path, encoding="utf-8", errors="replace") as text_file:
            return sum(1 for _ in text_file)
    except Exception as e:
        logger.debug(f"Could not analyze file with replacement: {file_path}: {e}")
        return 0

format_dir_metrics(content, metrics=())

Return the space-prefixed metrics suffix for a directory node.

Wraps :func:format_metrics_suffix, reading the totals from a directory's structure dict and keeping only the requested metrics that are actually present on that directory — while preserving the requested display order. Returns "" for a non-dict node.

Parameters:

Name Type Description Default
content Any

The directory's structure dict (or any value; non-dicts yield an empty string).

required
metrics Sequence[str]

The metrics to display, in order.

()

Returns:

Type Description
str

The metrics suffix (with a leading space) or an empty string.

Source code in recursivist/metrics.py
def format_dir_metrics(content: Any, metrics: Sequence[str] = ()) -> str:
    """Return the space-prefixed metrics suffix for a directory node.

    Wraps :func:`format_metrics_suffix`, reading the totals from a directory's
    structure dict and keeping only the requested metrics that are actually
    present on that directory — while preserving the requested display order.
    Returns ``""`` for a non-dict node.

    Args:
        content: The directory's structure dict (or any value; non-dicts yield
            an empty string).
        metrics: The metrics to display, in order.

    Returns:
        The metrics suffix (with a leading space) or an empty string.
    """
    if not isinstance(content, dict):
        return ""
    present = [m for m in metrics if f"_{m}" in content]
    return format_metrics_suffix(
        content.get("_loc", 0),
        content.get("_size", 0),
        content.get("_mtime", 0.0),
        present,
    )

format_metrics(loc=0, size=0, mtime=0.0, metrics=())

Build the parenthetical metrics annotation for a file or directory.

Includes exactly the metrics named in metrics, in that order — e.g. metrics=("size", "loc") yields "(4.2 KB, 120 lines)". The metric names are those defined in :mod:recursivist.flags: "loc", "size" and "mtime".

Parameters:

Name Type Description Default
loc int

Lines-of-code count.

0
size int

Size in bytes.

0
mtime float

Modification time (seconds since epoch).

0.0
metrics Sequence[str]

The metrics to include, in display order.

()

Returns:

Type Description
str

The annotation string including the surrounding parentheses, or an

str

empty string when metrics is empty.

Source code in recursivist/metrics.py
def format_metrics(
    loc: int = 0,
    size: int = 0,
    mtime: float = 0.0,
    metrics: Sequence[str] = (),
) -> str:
    """Build the parenthetical metrics annotation for a file or directory.

    Includes exactly the metrics named in *metrics*, in that order — e.g.
    ``metrics=("size", "loc")`` yields ``"(4.2 KB, 120 lines)"``. The metric
    names are those defined in :mod:`recursivist.flags`: ``"loc"``, ``"size"``
    and ``"mtime"``.

    Args:
        loc: Lines-of-code count.
        size: Size in bytes.
        mtime: Modification time (seconds since epoch).
        metrics: The metrics to include, in display order.

    Returns:
        The annotation string including the surrounding parentheses, or an
        empty string when *metrics* is empty.
    """
    renderers = {
        "loc": lambda: f"{loc} line" if loc == 1 else f"{loc} lines",
        "size": lambda: format_size(size),
        "mtime": lambda: format_timestamp(mtime),
    }
    parts = [renderers[m]() for m in metrics if m in renderers]
    return f"({', '.join(parts)})" if parts else ""

format_metrics_suffix(loc=0, size=0, mtime=0.0, metrics=())

Like :func:format_metrics but prefixed with a single space.

Convenient for appending directly after a file or directory name. Returns an empty string (no leading space) when metrics is empty.

Source code in recursivist/metrics.py
def format_metrics_suffix(
    loc: int = 0,
    size: int = 0,
    mtime: float = 0.0,
    metrics: Sequence[str] = (),
) -> str:
    """Like :func:`format_metrics` but prefixed with a single space.

    Convenient for appending directly after a file or directory name. Returns
    an empty string (no leading space) when *metrics* is empty.
    """
    annotation = format_metrics(loc, size, mtime, metrics)
    return f" {annotation}" if annotation else ""

format_size(size_in_bytes)

Format a byte count as a human-readable size string.

Scales the value to bytes, KB, MB, or GB and formats it with one decimal place for every unit above bytes.

Parameters:

Name Type Description Default
size_in_bytes int

Size in bytes.

required

Returns:

Type Description
str

A human-readable size string (e.g. "512 B" or "4.2 MB").

Source code in recursivist/metrics.py
def format_size(size_in_bytes: int) -> str:
    """Format a byte count as a human-readable size string.

    Scales the value to bytes, KB, MB, or GB and formats it with one decimal
    place for every unit above bytes.

    Args:
        size_in_bytes: Size in bytes.

    Returns:
        A human-readable size string (e.g. ``"512 B"`` or ``"4.2 MB"``).
    """
    if size_in_bytes < 1024:
        return f"{size_in_bytes} B"
    elif size_in_bytes < 1024 * 1024:
        return f"{size_in_bytes / 1024:.1f} KB"
    elif size_in_bytes < 1024 * 1024 * 1024:
        return f"{size_in_bytes / (1024 * 1024):.1f} MB"
    else:
        return f"{size_in_bytes / (1024 * 1024 * 1024):.1f} GB"

format_timestamp(timestamp)

Format a Unix timestamp as a human-readable, recency-aware string.

The representation becomes coarser as the timestamp gets older:

  • Today: "Today HH:MM"
  • Yesterday: "Yesterday HH:MM"
  • Within the last week: abbreviated weekday and time (e.g. "Mon 14:30")
  • Earlier this year: abbreviated month and day (e.g. "Mar 15")
  • Older: "YYYY-MM-DD"

Parameters:

Name Type Description Default
timestamp float

Seconds since the epoch.

required

Returns:

Type Description
str

The formatted date/time string, or "-" when timestamp is zero or

str

falls outside the representable range.

Source code in recursivist/metrics.py
def format_timestamp(timestamp: float) -> str:
    """Format a Unix timestamp as a human-readable, recency-aware string.

    The representation becomes coarser as the timestamp gets older:

    - Today: ``"Today HH:MM"``
    - Yesterday: ``"Yesterday HH:MM"``
    - Within the last week: abbreviated weekday and time (e.g. ``"Mon 14:30"``)
    - Earlier this year: abbreviated month and day (e.g. ``"Mar 15"``)
    - Older: ``"YYYY-MM-DD"``

    Args:
        timestamp: Seconds since the epoch.

    Returns:
        The formatted date/time string, or ``"-"`` when *timestamp* is zero or
        falls outside the representable range.
    """
    if not timestamp:
        return "-"
    try:
        dt_object = dt.fromtimestamp(timestamp)
    except (OSError, OverflowError, ValueError):
        return "-"
    current_dt = dt.now()
    current_date = current_dt.date()
    if dt_object.date() == current_date:
        return f"Today {dt_object.strftime('%H:%M')}"
    elif dt_object.date() == current_date - datetime.timedelta(days=1):
        return f"Yesterday {dt_object.strftime('%H:%M')}"
    elif current_date - dt_object.date() < datetime.timedelta(days=7):
        return dt_object.strftime("%a %H:%M")
    elif dt_object.year == current_dt.year:
        return dt_object.strftime("%b %d")
    else:
        return dt_object.strftime("%Y-%m-%d")

get_file_mtime(file_path)

Return a file's modification time in seconds since the epoch.

Parameters:

Name Type Description Default
file_path str

Path to the file.

required

Returns:

Type Description
float

The modification time as a float, or 0.0 if the file cannot be

float

accessed.

Source code in recursivist/metrics.py
def get_file_mtime(file_path: str) -> float:
    """Return a file's modification time in seconds since the epoch.

    Args:
        file_path: Path to the file.

    Returns:
        The modification time as a float, or ``0.0`` if the file cannot be
        accessed.
    """
    try:
        return os.path.getmtime(file_path)
    except Exception as e:
        logger.debug(f"Could not get modification time for {file_path}: {e}")
        return 0.0

get_file_size(file_path)

Return the size of a file in bytes.

Parameters:

Name Type Description Default
file_path str

Path to the file whose size should be retrieved.

required

Returns:

Type Description
int

Size of the file in bytes, or 0 when the file cannot be

int

accessed (e.g., permission error or the path no longer exists).

Source code in recursivist/metrics.py
def get_file_size(file_path: str) -> int:
    """Return the size of a file in bytes.

    Args:
        file_path: Path to the file whose size should be retrieved.

    Returns:
        Size of the file in bytes, or ``0`` when the file cannot be
        accessed (e.g., permission error or the path no longer exists).
    """
    try:
        return os.path.getsize(file_path)
    except Exception as e:
        logger.debug(f"Could not get size for {file_path}: {e}")
        return 0

Colors

recursivist.colors

Deterministic color assignment for file extensions.

Generates a stable, visually distinct hex color per file extension using a hash of the extension, with a collision-avoidance pass over already-assigned colors. Also provides WCAG 2.1 contrast helpers used by renderers that draw onto a known background (such as the HTML exporter) to guarantee legible text. Pure standard library.

WCAG_AAA_LARGE_TEXT = 4.5 module-attribute

WCAG 2.1 level AAA minimum contrast ratio for large text (>=18pt, or >=14pt bold).

WCAG_AAA_NORMAL_TEXT = 7.0 module-attribute

WCAG 2.1 level AAA minimum contrast ratio for normal-sized body text.

WCAG_AA_LARGE_TEXT = 3.0 module-attribute

WCAG 2.1 level AA minimum contrast ratio for large text (>=18pt, or >=14pt bold).

WCAG_AA_NORMAL_TEXT = 4.5 module-attribute

WCAG 2.1 level AA minimum contrast ratio for normal-sized body text.

color_distance(color1, color2)

Calculate the perceptual distance between two RGB colors.

Uses a weighted Euclidean distance formula that approximates human color perception by emphasising the green channel over red and blue.

Parameters:

Name Type Description Default
color1 tuple[int, int, int]

First color as an (r, g, b) tuple with component values in the range 0255.

required
color2 tuple[int, int, int]

Second color as an (r, g, b) tuple with component values in the range 0255.

required

Returns:

Type Description
float

A non-negative float representing the perceptual distance; 0.0

float

means the colors are identical and larger values indicate greater

float

visual difference.

Source code in recursivist/colors.py
def color_distance(color1: tuple[int, int, int], color2: tuple[int, int, int]) -> float:
    """Calculate the perceptual distance between two RGB colors.

    Uses a weighted Euclidean distance formula that approximates human color
    perception by emphasising the green channel over red and blue.

    Args:
        color1: First color as an ``(r, g, b)`` tuple with component values
            in the range ``0``–``255``.
        color2: Second color as an ``(r, g, b)`` tuple with component values
            in the range ``0``–``255``.

    Returns:
        A non-negative float representing the perceptual distance; ``0.0``
        means the colors are identical and larger values indicate greater
        visual difference.
    """
    r1, g1, b1 = [x / 255 for x in color1]
    r2, g2, b2 = [x / 255 for x in color2]
    r_weight, g_weight, b_weight = 0.3, 0.59, 0.11
    dist = math.sqrt(
        r_weight * (r1 - r2) ** 2
        + g_weight * (g1 - g2) ** 2
        + b_weight * (b1 - b2) ** 2
    )
    return dist

contrast_ratio(color1, color2)

Calculate the WCAG contrast ratio between two colors.

Parameters:

Name Type Description Default
color1 tuple[int, int, int]

First color as an (r, g, b) tuple with component values in the range 0255.

required
color2 tuple[int, int, int]

Second color as an (r, g, b) tuple with component values in the range 0255.

required

Returns:

Type Description
float

The contrast ratio, from 1.0 (identical luminance) to 21.0

float

(black against white). WCAG 2.1 requires at least 4.5 for normal

float

body text at level AA and 3.0 for large text.

Source code in recursivist/colors.py
def contrast_ratio(color1: tuple[int, int, int], color2: tuple[int, int, int]) -> float:
    """Calculate the WCAG contrast ratio between two colors.

    Args:
        color1: First color as an ``(r, g, b)`` tuple with component values
            in the range ``0``–``255``.
        color2: Second color as an ``(r, g, b)`` tuple with component values
            in the range ``0``–``255``.

    Returns:
        The contrast ratio, from ``1.0`` (identical luminance) to ``21.0``
        (black against white). WCAG 2.1 requires at least ``4.5`` for normal
        body text at level AA and ``3.0`` for large text.
    """
    luminance1 = relative_luminance(color1)
    luminance2 = relative_luminance(color2)
    lighter = max(luminance1, luminance2)
    darker = min(luminance1, luminance2)
    return (lighter + 0.05) / (darker + 0.05)

ensure_contrast(hex_color, background='#ffffff', min_ratio=WCAG_AA_NORMAL_TEXT) cached

Adjust a color until it meets a WCAG contrast ratio against background.

The hue is preserved so extensions stay recognisable and mutually distinguishable; only brightness (and, if brightness alone is not enough, saturation) is changed. Colors that already meet min_ratio are returned unchanged, so this is a no-op for compliant input.

Colors are darkened against light backgrounds and lightened against dark ones, whichever direction can reach the required ratio.

Parameters:

Name Type Description Default
hex_color str

Foreground color as a hex string, with or without a leading '#'.

required
background str

Background color the text is drawn on, as a hex string.

'#ffffff'
min_ratio float

Minimum acceptable contrast ratio. Defaults to 4.5, the WCAG 2.1 level AA threshold for normal-sized text.

WCAG_AA_NORMAL_TEXT

Returns:

Type Description
str

A CSS hex color string that meets min_ratio against background,

str

or (if no adjustment of this hue can reach the ratio) the closest

str

achievable color.

Source code in recursivist/colors.py
@lru_cache(maxsize=512)
def ensure_contrast(
    hex_color: str,
    background: str = "#ffffff",
    min_ratio: float = WCAG_AA_NORMAL_TEXT,
) -> str:
    """Adjust a color until it meets a WCAG contrast ratio against *background*.

    The hue is preserved so extensions stay recognisable and mutually
    distinguishable; only brightness (and, if brightness alone is not enough,
    saturation) is changed. Colors that already meet *min_ratio* are returned
    unchanged, so this is a no-op for compliant input.

    Colors are darkened against light backgrounds and lightened against dark
    ones, whichever direction can reach the required ratio.

    Args:
        hex_color: Foreground color as a hex string, with or without a
            leading ``'#'``.
        background: Background color the text is drawn on, as a hex string.
        min_ratio: Minimum acceptable contrast ratio. Defaults to ``4.5``,
            the WCAG 2.1 level AA threshold for normal-sized text.

    Returns:
        A CSS hex color string that meets *min_ratio* against *background*,
        or (if no adjustment of this hue can reach the ratio) the closest
        achievable color.
    """
    foreground = hex_to_rgb(hex_color)
    background_rgb = hex_to_rgb(background)
    if contrast_ratio(foreground, background_rgb) >= min_ratio:
        return hex_color
    hue, saturation, value = colorsys.rgb_to_hsv(*[c / 255 for c in foreground])
    darken = contrast_ratio((0, 0, 0), background_rgb) >= contrast_ratio(
        (255, 255, 255), background_rgb
    )
    steps = 128
    best_color = foreground
    best_ratio = contrast_ratio(foreground, background_rgb)
    candidates = []
    for step in range(1, steps + 1):
        fraction = step / steps
        if darken:
            candidates.append((hue, saturation, value * (1.0 - fraction)))
        else:
            candidates.append((hue, saturation, value + (1.0 - value) * fraction))
    if not darken:
        for step in range(1, steps + 1):
            fraction = step / steps
            candidates.append((hue, saturation * (1.0 - fraction), 1.0))
    for candidate_hsv in candidates:
        candidate = _hsv_to_rgb255(*candidate_hsv)
        ratio = contrast_ratio(candidate, background_rgb)
        if ratio >= min_ratio:
            return rgb_to_hex(candidate)
        if ratio > best_ratio:
            best_ratio = ratio
            best_color = candidate
    return rgb_to_hex(best_color)

generate_color_for_extension(extension)

Generate a stable, visually distinct color for a file extension.

The color is derived deterministically from a hash of the extension, so a given extension always maps to the same color within a session. Candidate colors are nudged through hue/saturation/value variations until they are far enough from every previously assigned color, keeping distinct extensions visually separable. The leading dot is optional and ignored, so "py" and ".py" share a color. An empty extension maps to white.

Parameters:

Name Type Description Default
extension str

File extension, with or without a leading dot.

required

Returns:

Type Description
str

A CSS hex color string (e.g., "#FF5733").

Source code in recursivist/colors.py
def generate_color_for_extension(extension: str) -> str:
    """Generate a stable, visually distinct color for a file extension.

    The color is derived deterministically from a hash of the extension, so a
    given extension always maps to the same color within a session. Candidate
    colors are nudged through hue/saturation/value variations until they are
    far enough from every previously assigned color, keeping distinct
    extensions visually separable. The leading dot is optional and ignored, so
    ``"py"`` and ``".py"`` share a color. An empty extension maps to white.

    Args:
        extension: File extension, with or without a leading dot.

    Returns:
        A CSS hex color string (e.g., ``"#FF5733"``).
    """
    if not extension:
        return "#FFFFFF"
    normalized_ext = extension
    if not extension.startswith("."):
        normalized_ext = "." + extension
    if extension in _EXTENSION_COLORS:
        return _EXTENSION_COLORS[extension]
    if extension != normalized_ext and normalized_ext in _EXTENSION_COLORS:
        color = _EXTENSION_COLORS[normalized_ext]
        _EXTENSION_COLORS[extension] = color
        return color
    hash_bytes = hashlib.md5(normalized_ext.encode(), usedforsecurity=False).digest()
    hue_int = int.from_bytes(hash_bytes[0:4], byteorder="big")
    hue = (hue_int % 360) / 360.0
    sat_int = hash_bytes[4]
    saturation = 0.65 + (sat_int % 26) / 100.0
    val_int = hash_bytes[5]
    value = 0.85 + (val_int % 16) / 100.0
    min_acceptable_distance = 0.15
    max_attempts = 15
    rgb = colorsys.hsv_to_rgb(hue, saturation, value)
    initial_color = (int(rgb[0] * 255), int(rgb[1] * 255), int(rgb[2] * 255))
    if not _EXTENSION_COLORS:
        hex_color = rgb_to_hex(initial_color)
        _EXTENSION_COLORS[extension] = hex_color
        if extension != normalized_ext:
            _EXTENSION_COLORS[normalized_ext] = hex_color
        return hex_color
    best_color = initial_color
    best_min_distance = 0.0
    for attempt in range(max_attempts):
        test_hue = (hue + (attempt * 0.1)) % 1.0
        test_sat = min(1.0, saturation + (attempt * 0.02))
        test_val = max(0.8, value - (attempt * 0.01))
        rgb = colorsys.hsv_to_rgb(test_hue, test_sat, test_val)
        test_color = (int(rgb[0] * 255), int(rgb[1] * 255), int(rgb[2] * 255))
        min_distance = float("inf")
        for existing_color in _EXTENSION_COLORS.values():
            existing_rgb = hex_to_rgb(existing_color)
            distance = color_distance(test_color, existing_rgb)
            min_distance = min(min_distance, distance)
        if min_distance > best_min_distance:
            best_min_distance = min_distance
            best_color = test_color
        if min_distance >= min_acceptable_distance:
            break
    hex_color = rgb_to_hex(best_color)
    _EXTENSION_COLORS[extension] = hex_color
    if extension != normalized_ext:
        _EXTENSION_COLORS[normalized_ext] = hex_color
    return hex_color

hex_to_rgb(hex_color)

Convert a CSS hex color string to an (r, g, b) tuple.

Parameters:

Name Type Description Default
hex_color str

Six-digit hex color string, optionally prefixed with '#' (e.g., "#FF5733" or "FF5733").

required

Returns:

Type Description
int

A three-tuple of integers (red, green, blue) in the range

int

0255.

Source code in recursivist/colors.py
def hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
    """Convert a CSS hex color string to an ``(r, g, b)`` tuple.

    Args:
        hex_color: Six-digit hex color string, optionally prefixed with
            ``'#'`` (e.g., ``"#FF5733"`` or ``"FF5733"``).

    Returns:
        A three-tuple of integers ``(red, green, blue)`` in the range
        ``0``–``255``.
    """
    hex_color = hex_color.lstrip("#")
    return cast(
        tuple[int, int, int], tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
    )

relative_luminance(color)

Calculate the WCAG relative luminance of an sRGB color.

Implements the definition given in WCAG 2.1: each channel is normalised to 01, linearised to remove the sRGB transfer function, and then combined with the standard luminance coefficients.

Parameters:

Name Type Description Default
color tuple[int, int, int]

Color as an (r, g, b) tuple with component values in the range 0255.

required

Returns:

Type Description
float

The relative luminance, from 0.0 (black) to 1.0 (white).

Source code in recursivist/colors.py
def relative_luminance(color: tuple[int, int, int]) -> float:
    """Calculate the WCAG relative luminance of an sRGB color.

    Implements the definition given in WCAG 2.1: each channel is normalised
    to ``0``–``1``, linearised to remove the sRGB transfer function, and then
    combined with the standard luminance coefficients.

    Args:
        color: Color as an ``(r, g, b)`` tuple with component values in the
            range ``0``–``255``.

    Returns:
        The relative luminance, from ``0.0`` (black) to ``1.0`` (white).
    """
    linear = []
    for component in color:
        channel = component / 255
        if channel <= 0.03928:
            linear.append(channel / 12.92)
        else:
            linear.append(((channel + 0.055) / 1.055) ** 2.4)
    red, green, blue = linear
    return 0.2126 * red + 0.7152 * green + 0.0722 * blue

rgb_to_hex(color)

Convert an (r, g, b) tuple to a CSS hex color string.

Parameters:

Name Type Description Default
color tuple[int, int, int]

Three-tuple of integers (red, green, blue) in the range 0255.

required

Returns:

Type Description
str

A lowercase six-digit hex color string prefixed with '#'.

Source code in recursivist/colors.py
def rgb_to_hex(color: tuple[int, int, int]) -> str:
    """Convert an ``(r, g, b)`` tuple to a CSS hex color string.

    Args:
        color: Three-tuple of integers ``(red, green, blue)`` in the range
            ``0``–``255``.

    Returns:
        A lowercase six-digit hex color string prefixed with ``'#'``.
    """
    return _HEX_FORMAT.format(*color)

Icons

recursivist.icons

Nerd Font icon mappings for files and directories.

This module provides icon lookup utilities based on Nerd Font glyph codes. Icons are resolved in priority order:

  1. Exact filename match (e.g., Dockerfile, package.json)
  2. File extension match (e.g., .py, .ts)
  3. Named folder match (e.g., node_modules, .git)
  4. Generic fallback icons for unknown files and directories

get_icon(filename, is_dir=False, style='emoji', is_empty=False)

Return the icon for a file or directory in the requested style.

With the "emoji" style, a single generic file emoji is returned for files and an open or closed folder emoji for directories. With the "nerd" style, a Nerd Font glyph is resolved in priority order.

For files:

  1. Exact filename match in EXACT_MATCH_ICONS (case-insensitive).
  2. File-extension match in EXTENSION_ICONS.
  3. The DEFAULT_NERD_FILE fallback.

For directories, FOLDER_ICONS is consulted first (so well-known folders keep their distinctive glyph regardless of their contents), falling back to the open or closed generic folder glyph depending on is_empty.

Parameters:

Name Type Description Default
filename str

Name of the file or directory (basename only, not a full path). Matched case-insensitively.

required
is_dir bool

When True, treat filename as a directory name and look up folder icons instead of file icons.

False
style str

Icon style to use, either "emoji" or "nerd".

'emoji'
is_empty bool

Only meaningful when is_dir is set. When True, the directory holds nothing that is being displayed and the closed folder icon is used; otherwise the open folder icon is used.

False

Returns:

Type Description
str

A single Unicode character containing the matching glyph.

Source code in recursivist/icons.py
def get_icon(
    filename: str,
    is_dir: bool = False,
    style: str = "emoji",
    is_empty: bool = False,
) -> str:
    """Return the icon for a file or directory in the requested style.

    With the ``"emoji"`` style, a single generic file emoji is returned for
    files and an open or closed folder emoji for directories. With the
    ``"nerd"`` style, a Nerd Font glyph is resolved in priority order.

    For files:

    1. Exact filename match in ``EXACT_MATCH_ICONS`` (case-insensitive).
    2. File-extension match in ``EXTENSION_ICONS``.
    3. The ``DEFAULT_NERD_FILE`` fallback.

    For directories, ``FOLDER_ICONS`` is consulted first (so well-known
    folders keep their distinctive glyph regardless of their contents),
    falling back to the open or closed generic folder glyph depending on
    *is_empty*.

    Args:
        filename: Name of the file or directory (basename only, not a full
            path). Matched case-insensitively.
        is_dir: When ``True``, treat *filename* as a directory name and look
            up folder icons instead of file icons.
        style: Icon style to use, either ``"emoji"`` or ``"nerd"``.
        is_empty: Only meaningful when *is_dir* is set. When ``True``, the
            directory holds nothing that is being displayed and the closed
            folder icon is used; otherwise the open folder icon is used.

    Returns:
        A single Unicode character containing the matching glyph.
    """
    if style == "emoji":
        if is_dir:
            return DEFAULT_EMOJI_FOLDER if is_empty else DEFAULT_EMOJI_FOLDER_OPEN
        return DEFAULT_EMOJI_FILE

    filename_lower = filename.lower()

    if is_dir:
        default_folder = DEFAULT_NERD_FOLDER if is_empty else DEFAULT_NERD_FOLDER_OPEN
        return FOLDER_ICONS.get(filename_lower, default_folder)

    if filename_lower in EXACT_MATCH_ICONS:
        return EXACT_MATCH_ICONS[filename_lower]

    _, ext = os.path.splitext(filename_lower)
    return EXTENSION_ICONS.get(ext, DEFAULT_NERD_FILE)

Git Status

recursivist.git_status

Git status lookup.

Wraps git status --porcelain and maps changed/untracked paths back to a location relative to the directory being visualised. Pure standard library.

get_git_status(directory)

Get Git status for files relative to a given directory.

Runs git status --porcelain -z from the repository root and maps every changed/untracked path back to a path relative to directory, filtering out files that live outside of it.

Status characters returned: - 'U': Untracked (?? in porcelain output) - 'M': Modified (working-tree or staged modification) - 'A': Added / staged for the first time (includes renames) - 'D': Deleted (working-tree or staged deletion)

Parameters:

Name Type Description Default
directory str

Absolute path to the directory being visualised. Must be inside a Git repository.

required

Returns:

Type Description
dict[str, str]

{relative_path: status_char} where relative_path uses forward

dict[str, str]

slashes regardless of OS, or an empty dict when Git is unavailable or

dict[str, str]

the directory is not tracked.

Source code in recursivist/git_status.py
def get_git_status(directory: str) -> dict[str, str]:
    """Get Git status for files relative to a given directory.

    Runs ``git status --porcelain -z`` from the repository root and maps every
    changed/untracked path back to a path relative to *directory*, filtering
    out files that live outside of it.

    Status characters returned:
    - ``'U'``: Untracked (``??`` in porcelain output)
    - ``'M'``: Modified (working-tree or staged modification)
    - ``'A'``: Added / staged for the first time (includes renames)
    - ``'D'``: Deleted (working-tree or staged deletion)

    Args:
        directory: Absolute path to the directory being visualised. Must be
            inside a Git repository.

    Returns:
        ``{relative_path: status_char}`` where *relative_path* uses forward
        slashes regardless of OS, or an empty dict when Git is unavailable or
        the directory is not tracked.
    """
    import subprocess

    try:
        root_result = subprocess.run(
            ["git", "rev-parse", "--show-toplevel"],
            cwd=directory,
            capture_output=True,
            text=True,
        )
        if root_result.returncode != 0:
            return {}
        git_root = root_result.stdout.strip()

        status_result = subprocess.run(
            ["git", "status", "--porcelain", "-z"],
            cwd=git_root,
            capture_output=True,
            text=True,
        )
        if status_result.returncode != 0:
            return {}

        status_map: dict[str, str] = {}
        records = status_result.stdout.split("\0")
        i = 0
        while i < len(records):
            entry = records[i]
            i += 1
            if len(entry) < 4:
                continue
            xy = entry[:2]
            path = entry[3:]

            x, y = xy[0], xy[1]
            if x == "R" or x == "C":
                i += 1
            if x == "?" and y == "?":
                status = "U"
            elif x == "D" or y == "D":
                status = "D"
            elif x == "A" or x == "R":
                status = "A"
            else:
                status = "M"

            abs_file = os.path.normpath(
                os.path.join(git_root, path.replace("/", os.sep))
            )
            try:
                rel = os.path.relpath(abs_file, directory)
                if not rel.startswith(".."):
                    status_map[rel.replace(os.sep, "/")] = status
            except ValueError:
                pass

        return status_map
    except Exception as e:
        logger.debug(f"Could not get git status for {directory}: {e}")
        return {}

GitHub

A GitHub repository URL passed to visualize, export, or compare is resolved here. parse_github_url turns a URL into a GitHubTarget, and checkout_repository downloads the repository's source archive into a temporary directory and yields a RepoCheckout whose local_root is scanned like any other directory. apply_github_urls rewrites file paths to GitHub blob URLs for --full-path output.

recursivist.github

Remote GitHub repository support.

Lets the visualize, export and compare commands accept a GitHub repository URL anywhere they accept a local directory. A repository is materialized by downloading its source archive from codeload.github.com and extracting it into a temporary directory, after which the existing local-directory scanner, renderers and exporters are reused unchanged.

The archive endpoint is used rather than the REST API on purpose: the REST API limits unauthenticated clients to 60 requests/hour (shared per public IP), which is easily exhausted, whereas archive downloads are not subject to that limit. Only the default branch is resolved through a lightweight, unlimited info/refs request when the caller did not pin a ref explicitly.

Because a hosted repository already reflects its ignore rules — files excluded by a .gitignore are simply absent — and because every file in a checkout shares the same Git status and effective modification time (the tip commit's), the --ignore-file, --git-status, --sort-by-git-status, --mtime and --sort-by-mtime options are not meaningful for a GitHub input and are skipped. The lines-of-code and size annotations are retained, since they are derived from the file contents. The --full-path option still applies, but instead of a filesystem path it shows each file's canonical GitHub blob URL.

Only the Python standard library is used. When a token is present in the GITHUB_TOKEN or GH_TOKEN environment variable it is sent with every request, which raises the rate limits and enables access to private repositories.

GitHubError

Bases: Exception

Raised when a GitHub repository cannot be resolved or downloaded.

Carries a human-readable message suitable for surfacing directly to the user (e.g. an invalid URL, a missing repository, a rate-limit response, or a network/extraction failure).

Source code in recursivist/github.py
class GitHubError(Exception):
    """Raised when a GitHub repository cannot be resolved or downloaded.

    Carries a human-readable message suitable for surfacing directly to the
    user (e.g. an invalid URL, a missing repository, a rate-limit response, or
    a network/extraction failure).
    """

GitHubTarget dataclass

A parsed reference to a GitHub repository or a subtree within one.

Attributes:

Name Type Description
owner str

Repository owner (user or organization).

repo str

Repository name, without any trailing .git.

ref str | None

The branch, tag, or commit the caller pinned via /tree/<ref> or /blob/<ref>, or None to use the repository's default branch.

subpath str

A forward-slashed path within the repository to treat as the root of the scan, or "" for the whole repository.

Source code in recursivist/github.py
@dataclass(frozen=True)
class GitHubTarget:
    """A parsed reference to a GitHub repository or a subtree within one.

    Attributes:
        owner: Repository owner (user or organization).
        repo: Repository name, without any trailing ``.git``.
        ref: The branch, tag, or commit the caller pinned via
            ``/tree/<ref>`` or ``/blob/<ref>``, or ``None`` to use the
            repository's default branch.
        subpath: A forward-slashed path within the repository to treat as the
            root of the scan, or ``""`` for the whole repository.
    """

    owner: str
    repo: str
    ref: str | None = None
    subpath: str = ""

    @property
    def slug(self) -> str:
        """The ``owner/repo`` identifier."""
        return f"{self.owner}/{self.repo}"

    @property
    def display_name(self) -> str:
        """A short label for the scanned root (subpath basename or repo name)."""
        if self.subpath:
            return self.subpath.rstrip("/").split("/")[-1]
        return self.repo

    def blob_url(self, ref: str, relpath: str) -> str:
        """Return the canonical GitHub blob URL for a file.

        Args:
            ref: The concrete ref (branch, tag, or commit) to embed in the URL.
            relpath: The file's forward-slashed path relative to the repository
                root (already including any :attr:`subpath` prefix).

        Returns:
            A URL of the form
            ``https://github.com/<owner>/<repo>/blob/<ref>/<relpath>``.
        """
        clean = relpath.replace(os.sep, "/").lstrip("/")
        return f"{_WEB_HOST}/{self.owner}/{self.repo}/blob/{ref}/{clean}"

display_name property

A short label for the scanned root (subpath basename or repo name).

slug property

The owner/repo identifier.

blob_url(ref, relpath)

Return the canonical GitHub blob URL for a file.

Parameters:

Name Type Description Default
ref str

The concrete ref (branch, tag, or commit) to embed in the URL.

required
relpath str

The file's forward-slashed path relative to the repository root (already including any :attr:subpath prefix).

required

Returns:

Type Description
str

A URL of the form

str

https://github.com/<owner>/<repo>/blob/<ref>/<relpath>.

Source code in recursivist/github.py
def blob_url(self, ref: str, relpath: str) -> str:
    """Return the canonical GitHub blob URL for a file.

    Args:
        ref: The concrete ref (branch, tag, or commit) to embed in the URL.
        relpath: The file's forward-slashed path relative to the repository
            root (already including any :attr:`subpath` prefix).

    Returns:
        A URL of the form
        ``https://github.com/<owner>/<repo>/blob/<ref>/<relpath>``.
    """
    clean = relpath.replace(os.sep, "/").lstrip("/")
    return f"{_WEB_HOST}/{self.owner}/{self.repo}/blob/{ref}/{clean}"

RepoCheckout dataclass

A materialized GitHub repository on the local filesystem.

Attributes:

Name Type Description
target GitHubTarget

The :class:GitHubTarget that was checked out.

local_root str

Absolute path to the directory to scan — the extracted repository root, or the requested subpath within it.

ref str

The concrete ref that was downloaded (the pinned ref, or the resolved default branch).

root_name str

The display name for the scanned root (the repository name, or the last segment of the subpath).

Source code in recursivist/github.py
@dataclass(frozen=True)
class RepoCheckout:
    """A materialized GitHub repository on the local filesystem.

    Attributes:
        target: The :class:`GitHubTarget` that was checked out.
        local_root: Absolute path to the directory to scan — the extracted
            repository root, or the requested subpath within it.
        ref: The concrete ref that was downloaded (the pinned ref, or the
            resolved default branch).
        root_name: The display name for the scanned root (the repository name,
            or the last segment of the subpath).
    """

    target: GitHubTarget
    local_root: str
    ref: str
    root_name: str

apply_github_urls(structure, checkout)

Rewrite each file's display path to its GitHub blob URL, in place.

Used when --full-path is requested for a GitHub input: it walks structure and replaces every :class:~recursivist._models.FileEntry path with the file's canonical blob URL, so the unmodified renderers and exporters display GitHub URLs instead of temporary filesystem paths.

Parameters:

Name Type Description Default
structure dict[str, Any]

A scanned structure produced by :func:recursivist.scanner.get_directory_structure for the checkout's :attr:RepoCheckout.local_root.

required
checkout RepoCheckout

The checkout the structure was scanned from, supplying the owner, repo, ref, and subpath used to build URLs.

required

Returns:

Type Description
dict[str, Any]

The same structure object, with file paths rewritten.

Source code in recursivist/github.py
def apply_github_urls(
    structure: dict[str, Any], checkout: RepoCheckout
) -> dict[str, Any]:
    """Rewrite each file's display path to its GitHub blob URL, in place.

    Used when ``--full-path`` is requested for a GitHub input: it walks
    *structure* and replaces every :class:`~recursivist._models.FileEntry`
    ``path`` with the file's canonical blob URL, so the unmodified renderers
    and exporters display GitHub URLs instead of temporary filesystem paths.

    Args:
        structure: A scanned structure produced by
            :func:`recursivist.scanner.get_directory_structure` for the
            checkout's :attr:`RepoCheckout.local_root`.
        checkout: The checkout the structure was scanned from, supplying the
            owner, repo, ref, and subpath used to build URLs.

    Returns:
        The same *structure* object, with file paths rewritten.
    """
    target = checkout.target
    base_prefix = target.subpath.strip("/")

    def _walk(node: dict[str, Any], rel_dir: str) -> None:
        files = node.get("_files")
        if files:
            rewritten: list[FileEntry] = []
            for raw in files:
                entry = FileEntry.coerce(raw)
                rel_file = f"{rel_dir}/{entry.name}" if rel_dir else entry.name
                url = target.blob_url(checkout.ref, rel_file)
                rewritten.append(entry._replace(path=url))
            node["_files"] = rewritten
        for name, content in node.items():
            if name.startswith("_") or not isinstance(content, dict):
                continue
            next_dir = f"{rel_dir}/{name}" if rel_dir else name
            _walk(content, next_dir)

    _walk(structure, base_prefix)
    return structure

checkout_repository(target, token=None)

Download and extract a GitHub repository into a temporary directory.

Resolves the ref (using the default branch when the target does not pin one), downloads the source archive, and safely extracts it. The extracted files are removed when the context exits.

Parameters:

Name Type Description Default
target GitHubTarget

The repository (and optional subtree) to check out.

required
token str | None

Optional GitHub token; defaults to :func:get_github_token.

None

Yields:

Name Type Description
A RepoCheckout

class:RepoCheckout describing the local extraction.

Raises:

Type Description
GitHubError

If the repository cannot be resolved, downloaded, or extracted.

Source code in recursivist/github.py
@contextmanager
def checkout_repository(
    target: GitHubTarget, token: str | None = None
) -> Iterator[RepoCheckout]:
    """Download and extract a GitHub repository into a temporary directory.

    Resolves the ref (using the default branch when the target does not pin
    one), downloads the source archive, and safely extracts it. The extracted
    files are removed when the context exits.

    Args:
        target: The repository (and optional subtree) to check out.
        token: Optional GitHub token; defaults to :func:`get_github_token`.

    Yields:
        A :class:`RepoCheckout` describing the local extraction.

    Raises:
        GitHubError: If the repository cannot be resolved, downloaded, or
            extracted.
    """
    if token is None:
        token = get_github_token()
    ref = target.ref or resolve_default_branch(target, token)
    temp_dir = tempfile.mkdtemp(prefix="recursivist-gh-")
    try:
        archive_path = os.path.join(temp_dir, "archive.tar.gz")
        logger.debug("Downloading %s at ref '%s'", target.slug, ref)
        _download_archive(target, ref, token, archive_path)
        extract_dir = os.path.join(temp_dir, "extracted")
        os.makedirs(extract_dir, exist_ok=True)
        _safe_extract(archive_path, extract_dir)
        os.remove(archive_path)
        local_root = _locate_root(extract_dir, target)
        yield RepoCheckout(
            target=target,
            local_root=local_root,
            ref=ref,
            root_name=target.display_name,
        )
    finally:
        shutil.rmtree(temp_dir, ignore_errors=True)

commit_shas_equal(sha1, sha2)

Return whether two commit SHAs identify the same commit.

Handles abbreviated SHAs (as short as 7 characters, Git's conventional minimum) by treating one as a match for the other when it is a case-insensitive prefix. None never matches.

Parameters:

Name Type Description Default
sha1 str | None

The first commit SHA, or None.

required
sha2 str | None

The second commit SHA, or None.

required

Returns:

Type Description
bool

True if both are non-None and identify the same commit.

Source code in recursivist/github.py
def commit_shas_equal(sha1: str | None, sha2: str | None) -> bool:
    """Return whether two commit SHAs identify the same commit.

    Handles abbreviated SHAs (as short as 7 characters, Git's conventional
    minimum) by treating one as a match for the other when it is a
    case-insensitive prefix. ``None`` never matches.

    Args:
        sha1: The first commit SHA, or ``None``.
        sha2: The second commit SHA, or ``None``.

    Returns:
        ``True`` if both are non-``None`` and identify the same commit.
    """
    if sha1 is None or sha2 is None:
        return False
    a, b = sha1.lower(), sha2.lower()
    if a == b:
        return True
    shorter, longer = (a, b) if len(a) <= len(b) else (b, a)
    return len(shorter) >= 7 and longer.startswith(shorter)

get_github_token()

Return a GitHub token from the environment, if configured.

Looks up GITHUB_TOKEN first and then GH_TOKEN. When set, the token is sent with archive and ref requests, raising rate limits and permitting access to private repositories.

Returns:

Type Description
str | None

The token string, or None when neither variable is set.

Source code in recursivist/github.py
def get_github_token() -> str | None:
    """Return a GitHub token from the environment, if configured.

    Looks up ``GITHUB_TOKEN`` first and then ``GH_TOKEN``. When set, the token
    is sent with archive and ref requests, raising rate limits and permitting
    access to private repositories.

    Returns:
        The token string, or ``None`` when neither variable is set.
    """
    for var in ("GITHUB_TOKEN", "GH_TOKEN"):
        token = os.environ.get(var)
        if token:
            return token.strip()
    return None

is_github_url(text)

Return whether text looks like a GitHub repository reference.

This is a cheap syntactic check used to decide whether an argument should be treated as a remote repository rather than a local path; it does not contact the network.

Parameters:

Name Type Description Default
text str

The raw argument to test.

required

Returns:

Type Description
bool

True if text parses as a GitHub URL, False otherwise.

Source code in recursivist/github.py
def is_github_url(text: str) -> bool:
    """Return whether *text* looks like a GitHub repository reference.

    This is a cheap syntactic check used to decide whether an argument should
    be treated as a remote repository rather than a local path; it does not
    contact the network.

    Args:
        text: The raw argument to test.

    Returns:
        ``True`` if *text* parses as a GitHub URL, ``False`` otherwise.
    """
    return parse_github_url(text) is not None

parse_github_url(text)

Parse a GitHub repository URL into a :class:GitHubTarget.

Accepts the common HTTPS forms (with or without scheme, www. or a trailing .git), an optional /tree/<ref>[/<subpath>] or /blob/<ref>/<subpath> selector, and the SSH form git@github.com:owner/repo.git.

When a /tree or /blob selector is present, the segment immediately after it is taken as the ref and everything beyond it as the subpath. Refs that themselves contain slashes (e.g. feature/x) therefore cannot be distinguished from a subpath by URL alone; pass such a repository without a selector, or pin the ref with a plain branch name.

Parameters:

Name Type Description Default
text str

The raw argument to parse.

required

Returns:

Type Description
GitHubTarget | None

The parsed :class:GitHubTarget, or None when text is not a

GitHubTarget | None

recognizable GitHub URL.

Source code in recursivist/github.py
def parse_github_url(text: str) -> GitHubTarget | None:
    """Parse a GitHub repository URL into a :class:`GitHubTarget`.

    Accepts the common HTTPS forms (with or without scheme, ``www.`` or a
    trailing ``.git``), an optional ``/tree/<ref>[/<subpath>]`` or
    ``/blob/<ref>/<subpath>`` selector, and the SSH form
    ``git@github.com:owner/repo.git``.

    When a ``/tree`` or ``/blob`` selector is present, the segment immediately
    after it is taken as the ref and everything beyond it as the subpath. Refs
    that themselves contain slashes (e.g. ``feature/x``) therefore cannot be
    distinguished from a subpath by URL alone; pass such a repository without a
    selector, or pin the ref with a plain branch name.

    Args:
        text: The raw argument to parse.

    Returns:
        The parsed :class:`GitHubTarget`, or ``None`` when *text* is not a
        recognizable GitHub URL.
    """
    if not text or "github.com" not in text:
        return None
    ssh = _SSH_RE.match(text)
    if ssh:
        return GitHubTarget(owner=ssh.group("owner"), repo=ssh.group("repo"))
    match = _HTTP_RE.match(text)
    if not match:
        return None
    subpath = (match.group("subpath") or "").strip("/")
    return GitHubTarget(
        owner=match.group("owner"),
        repo=match.group("repo"),
        ref=match.group("ref"),
        subpath=subpath,
    )

resolve_commit_shas(target, refs, token=None)

Resolve each ref in refs to a commit SHA using one advertisement fetch.

A single info/refs request is made and reused for every ref, so this is cheap even for several refs on the same repository. Each entry is resolved as follows:

  • None resolves to the commit the default branch (HEAD) points at.
  • A branch or tag name resolves to its tip commit; annotated tags resolve to the commit they dereference to.
  • A value that is not an advertised ref but looks like a commit SHA (7–40 hex characters) is returned as-is, lowercased, so explicit commit pins are supported.
  • Anything else resolves to None.

Parameters:

Name Type Description Default
target GitHubTarget

The repository to resolve against.

required
refs list[str | None]

The refs to resolve, in order. None means the default branch.

required
token str | None

Optional GitHub token for private repositories.

None

Returns:

Type Description
list[str | None]

A list the same length as refs, each a lowercase commit SHA or

list[str | None]

None when the ref could not be resolved.

Raises:

Type Description
GitHubError

If the repository is missing, private, or unreachable.

Source code in recursivist/github.py
def resolve_commit_shas(
    target: GitHubTarget,
    refs: list[str | None],
    token: str | None = None,
) -> list[str | None]:
    """Resolve each ref in *refs* to a commit SHA using one advertisement fetch.

    A single ``info/refs`` request is made and reused for every ref, so this is
    cheap even for several refs on the same repository. Each entry is resolved
    as follows:

    * ``None`` resolves to the commit the default branch (``HEAD``) points at.
    * A branch or tag name resolves to its tip commit; annotated tags resolve to
      the commit they dereference to.
    * A value that is not an advertised ref but looks like a commit SHA (7–40
      hex characters) is returned as-is, lowercased, so explicit commit pins are
      supported.
    * Anything else resolves to ``None``.

    Args:
        target: The repository to resolve against.
        refs: The refs to resolve, in order. ``None`` means the default branch.
        token: Optional GitHub token for private repositories.

    Returns:
        A list the same length as *refs*, each a lowercase commit SHA or
        ``None`` when the ref could not be resolved.

    Raises:
        GitHubError: If the repository is missing, private, or unreachable.
    """
    advertised = _parse_advertised_refs(_fetch_refs_advertisement(target, token))
    resolved: list[str | None] = []
    for ref in refs:
        if ref is None:
            resolved.append(advertised.get("HEAD"))
            continue
        sha = (
            advertised.get(f"refs/heads/{ref}")
            or advertised.get(f"refs/tags/{ref}")
            or advertised.get(ref)
        )
        if sha is None and _SHA_RE.fullmatch(ref):
            sha = ref.lower()
        resolved.append(sha)
    return resolved

resolve_default_branch(target, token=None)

Resolve a repository's default branch without using the REST API.

Reads the symbolic HEAD reference from the repository's Git smart-HTTP info/refs advertisement, which is not subject to the REST API's unauthenticated rate limit.

Parameters:

Name Type Description Default
target GitHubTarget

The repository whose default branch is wanted.

required
token str | None

Optional GitHub token for private repositories.

None

Returns:

Type Description
str

The default branch name (e.g. "main").

Raises:

Type Description
GitHubError

If the repository is missing or private without a valid token, or if the default branch cannot be determined.

Source code in recursivist/github.py
def resolve_default_branch(target: GitHubTarget, token: str | None = None) -> str:
    """Resolve a repository's default branch without using the REST API.

    Reads the symbolic ``HEAD`` reference from the repository's Git smart-HTTP
    ``info/refs`` advertisement, which is not subject to the REST API's
    unauthenticated rate limit.

    Args:
        target: The repository whose default branch is wanted.
        token: Optional GitHub token for private repositories.

    Returns:
        The default branch name (e.g. ``"main"``).

    Raises:
        GitHubError: If the repository is missing or private without a valid
            token, or if the default branch cannot be determined.
    """
    payload = _fetch_refs_advertisement(target, token)
    match = re.search(rb"symref=HEAD:refs/heads/([^\x00 \n]+)", payload)
    if not match:
        raise GitHubError(
            f"Could not determine the default branch for '{target.slug}'."
        )
    return match.group(1).decode("utf-8", "replace")

Configuration

recursivist.config

User configuration persistence.

Reads and writes recursivist's JSON settings file, resolving its location with Typer's platform-aware application directory. The only stored preference is currently the icon style.

get_config_path()

Return the path to the configuration file, creating its directory.

The location is resolved with :func:typer.get_app_dir, so it follows each platform's convention for application data. The parent directory is created if it does not already exist.

Returns:

Type Description
Path

Path to config.json inside the application directory.

Source code in recursivist/config.py
def get_config_path() -> Path:
    """Return the path to the configuration file, creating its directory.

    The location is resolved with :func:`typer.get_app_dir`, so it follows
    each platform's convention for application data. The parent directory is
    created if it does not already exist.

    Returns:
        Path to ``config.json`` inside the application directory.
    """
    app_dir = typer.get_app_dir(APP_NAME)
    config_dir = Path(app_dir)
    config_dir.mkdir(parents=True, exist_ok=True)
    return config_dir / "config.json"

load_config()

Load the user configuration from disk.

Returns:

Type Description
dict[str, Any]

The parsed configuration mapping, or the default

dict[str, Any]

{"icon_style": "emoji"} when the file is missing, unreadable, or

dict[str, Any]

does not contain a JSON object.

Source code in recursivist/config.py
def load_config() -> dict[str, Any]:
    """Load the user configuration from disk.

    Returns:
        The parsed configuration mapping, or the default
        ``{"icon_style": "emoji"}`` when the file is missing, unreadable, or
        does not contain a JSON object.
    """
    config_path = get_config_path()
    if config_path.is_file():
        try:
            with open(config_path) as f:
                config = json.load(f)
                if isinstance(config, dict):
                    return config
        except json.JSONDecodeError:
            pass
    return {"icon_style": "emoji"}

save_config(config)

Write the user configuration to disk as indented JSON.

Parameters:

Name Type Description Default
config dict[str, Any]

Configuration mapping to persist. Overwrites any existing file at the configuration path.

required
Source code in recursivist/config.py
def save_config(config: dict[str, Any]) -> None:
    """Write the user configuration to disk as indented JSON.

    Args:
        config: Configuration mapping to persist. Overwrites any existing
            file at the configuration path.
    """
    config_path = get_config_path()
    with open(config_path, "w") as f:
        json.dump(config, f, indent=4)

Example: Custom Analysis Script

This script scans a directory with metrics enabled, exports two formats, and prints a summary:

import sys

from recursivist.scanner import get_directory_structure
from recursivist.exporters import get_exporter
from recursivist.flags import DisplayOptions
from recursivist._models import FileEntry


def analyze_directory(directory_path: str) -> None:
    # Scan with lines-of-code and size tracking enabled
    structure, extensions = get_directory_structure(
        directory_path,
        exclude_dirs=["node_modules", ".git", ".venv"],
        exclude_extensions={".pyc", ".log", ".tmp"},
        sort_by_loc=True,
        sort_by_size=True,
    )

    # Describe how to sort and annotate, then export via the factory.
    # Here: order by lines of code, annotating each file with LOC then size.
    spec = DisplayOptions(sort_key="loc", metrics=("loc", "size"))
    for fmt, out in (("md", "analysis.md"), ("json", "analysis.json")):
        exporter = get_exporter(
            fmt,
            structure=structure,
            root_name=directory_path,
            spec=spec,
        )
        exporter.export(out)

    print(f"Directory: {directory_path}")
    print(f"Extensions: {sorted(extensions)}")
    print(f"Total lines of code: {structure.get('_loc', 0)}")
    print(f"Total size (bytes): {structure.get('_size', 0)}")

    # Collect every file as a FileEntry and find the largest by LOC.
    # FileEntry.coerce reads the canonical (name, path, loc, size, mtime)
    # slots positionally, so it needs no knowledge of which flags were set.
    def collect(struct: dict, path: str = "") -> list[tuple[str, FileEntry]]:
        found: list[tuple[str, FileEntry]] = []
        for entry in struct.get("_files", []):
            fe = FileEntry.coerce(entry)
            found.append((f"{path}/{fe.name}" if path else fe.name, fe))
        for name, content in struct.items():
            if isinstance(content, dict) and not name.startswith("_"):
                found.extend(collect(content, f"{path}/{name}" if path else name))
        return found

    files = collect(structure)
    files.sort(key=lambda item: item[1].loc, reverse=True)

    print("\nTop 5 files by lines of code:")
    for display_path, fe in files[:5]:
        print(f"  {fe.loc:>6} {display_path}")


if __name__ == "__main__":
    analyze_directory(sys.argv[1] if len(sys.argv) > 1 else ".")

Extending Recursivist

The modular layout makes the common extension points clear:

  • A new export format: subclass BaseExporter in a new module under recursivist/exporters/, implement export, and register it in the _EXPORTERS map in recursivist/exporters/__init__.py.
  • Custom filtering: extend should_exclude in recursivist/filtering.py.
  • Custom rendering: build on build_tree and display_tree in recursivist/tree.py.
  • A new metric: collect it in get_directory_structure (recursivist/scanner.py), thread it through FileEntry, register it in recursivist/flags.py (so it resolves into DisplayOptions), and surface it in the renderers, exporters, and CLI.

See the Development Guide for the full workflow.