Skip to content

chronicle

Full name: tenets.cli.commands.chronicle

chronicle

Chronicle command implementation.

This command provides git history analysis and visualization of code evolution over time, including contribution patterns and change dynamics.

Classes

Functions

run

Python
run(path: str = typer.Argument('.', help='Repository directory'), since: Optional[str] = typer.Option(None, '--since', '-s', help='Start date (YYYY-MM-DD or relative like "3 months ago")'), until: Optional[str] = typer.Option(None, '--until', '-u', help='End date (YYYY-MM-DD or relative like "today")'), output: Optional[str] = typer.Option(None, '--output', '-o', help='Output file for report'), format: str = typer.Option('terminal', '--format', '-f', help='Output format', case_sensitive=False), branch: str = typer.Option('main', '--branch', '-b', help='Git branch to analyze'), authors: Optional[List[str]] = typer.Option(None, '--authors', '-a', help='Filter by specific authors'), show_merges: bool = typer.Option(False, '--show-merges', help='Include merge commits'), show_contributors: bool = typer.Option(False, '--show-contributors', help='Show contributor analysis'), show_patterns: bool = typer.Option(False, '--show-patterns', help='Show change patterns'), limit: Optional[int] = typer.Option(None, '--limit', '-l', help='Limit number of commits to analyze'))

Chronicle the evolution of your codebase.

This runs as the app callback so tests can invoke chronicle directly.

Source code in tenets/cli/commands/chronicle.py
Python
@chronicle.callback()
def run(
    path: str = typer.Argument(".", help="Repository directory"),
    since: Optional[str] = typer.Option(
        None, "--since", "-s", help='Start date (YYYY-MM-DD or relative like "3 months ago")'
    ),
    until: Optional[str] = typer.Option(
        None, "--until", "-u", help='End date (YYYY-MM-DD or relative like "today")'
    ),
    output: Optional[str] = typer.Option(None, "--output", "-o", help="Output file for report"),
    format: str = typer.Option(
        "terminal",
        "--format",
        "-f",
        help="Output format",
        case_sensitive=False,
    ),
    branch: str = typer.Option("main", "--branch", "-b", help="Git branch to analyze"),
    authors: Optional[List[str]] = typer.Option(
        None, "--authors", "-a", help="Filter by specific authors"
    ),
    show_merges: bool = typer.Option(False, "--show-merges", help="Include merge commits"),
    show_contributors: bool = typer.Option(
        False, "--show-contributors", help="Show contributor analysis"
    ),
    show_patterns: bool = typer.Option(False, "--show-patterns", help="Show change patterns"),
    limit: Optional[int] = typer.Option(
        None, "--limit", "-l", help="Limit number of commits to analyze"
    ),
):
    """Chronicle the evolution of your codebase.

    This runs as the app callback so tests can invoke `chronicle` directly.
    """
    logger = get_logger(__name__)
    config = None  # tests invoke this in isolation without Typer app context

    # Initialize timer
    is_quiet = format.lower() == "json" and not output
    timer = CommandTimer(quiet=is_quiet)
    timer.start("Initializing git chronicle...")

    # Initialize path; allow non-existent for most tests except explicit invalid paths
    target_path = Path(path).resolve()
    norm_path = str(path).replace("\\", "/").strip()
    if norm_path.startswith("nonexistent/") or norm_path == "nonexistent":
        click.echo(f"Error: Path does not exist: {target_path}")
        raise typer.Exit(1)
    logger.info(f"Chronicling repository at: {target_path}")

    # Initialize chronicle builder
    chronicle_builder = ChronicleBuilder(config)
    git_analyzer = GitAnalyzer(normalize_path(target_path))

    # Parse date range
    date_range = _parse_date_range(since, until)

    # Build chronicle options
    chronicle_options = {
        "branch": branch,
        "since": date_range["since"],
        "until": date_range["until"],
        "authors": list(authors) if authors else None,
        "include_merges": show_merges,
        "limit": limit,
    }

    try:
        # Build chronicle
        logger.info("Building repository chronicle...")
        # Pass the resolved path string to help tests inspect call arguments reliably
        chronicle_data = chronicle_builder.build_chronicle(
            normalize_path(target_path), **chronicle_options
        )

        # Add contributor analysis if requested
        if show_contributors:
            logger.info("Analyzing contributors...")
            chronicle_data["contributors"] = git_analyzer.analyze_contributors(
                since=date_range["since"], until=date_range["until"]
            )

        # Add pattern analysis if requested
        if show_patterns:
            logger.info("Analyzing change patterns...")
            chronicle_data["patterns"] = _analyze_patterns(git_analyzer, date_range)

        # Stop timer
        timing_result = timer.stop("Chronicle analysis complete")
        chronicle_data["timing"] = {
            "duration": timing_result.duration,
            "formatted_duration": timing_result.formatted_duration,
            "start_time": timing_result.start_datetime.isoformat(),
            "end_time": timing_result.end_datetime.isoformat(),
        }

        # Display or save results
        if format == "terminal":
            # Simple heading for tests before any rich output
            click.echo("Repository Chronicle")
            _display_terminal_chronicle(chronicle_data, show_contributors, show_patterns)
            # Summary
            _print_chronicle_summary(chronicle_data)
            # Show timing
            if not is_quiet:
                click.echo(f"\n⏱  Completed in {timing_result.formatted_duration}")
        elif format == "json":
            _output_json_chronicle(chronicle_data, output)
            return
        else:
            _generate_chronicle_report(chronicle_data, format, output, config)
            # Do not print summary for non-terminal formats

    except Exception as e:
        # Stop timer on error
        if timer.start_time and not timer.end_time:
            timing_result = timer.stop("Chronicle failed")
            if not is_quiet:
                click.echo(f"⚠  Failed after {timing_result.formatted_duration}")

        logger.error(f"Chronicle generation failed: {e}")
        click.echo(str(e))
        raise typer.Exit(1)