Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ dependencies = [
]

[project.optional-dependencies]
watch = ["watchfiles>=0.21"]
dev = [
"pytest>=8.0",
"pytest-cov>=5.0",
Expand Down
40 changes: 40 additions & 0 deletions src/llmstxt_gen/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

import re
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Annotated

Expand All @@ -23,6 +25,7 @@
from llmstxt_gen.pruner import estimate_total_tokens, prune_modules
from llmstxt_gen.renderer import render_diff, render_full, render_mini, render_summary
from llmstxt_gen.walker import walk_repository
from llmstxt_gen.watcher import iter_changes
from llmstxt_gen.writer import write_outputs

app = typer.Typer(
Expand Down Expand Up @@ -136,8 +139,16 @@ def generate(
show_default=False,
),
] = None,
watch: Annotated[
bool,
typer.Option("--watch", help="Watch for file changes and regenerate automatically."),
] = False,
) -> None:
"""Generate ``llms.txt`` (and ``llms-full.txt``) for a project."""
if watch and diff:
typer.echo("Error: --watch and --diff cannot be used together.", err=True)
raise typer.Exit(code=1)

cfg = load_config(path, config_path=config)
if output_dir is not None:
cfg.output_dir = str(output_dir)
Expand Down Expand Up @@ -199,6 +210,35 @@ def generate(
for p in written:
typer.echo(f"wrote {p}")

if watch:
typer.echo(f"Watching for changes in {cfg.root.resolve()}...")
try:
for changes in iter_changes(cfg.root):
start_time = time.time()
# Re-collect modules (cache handles skipping unchanged)
modules = _collect_modules(
cfg,
verbose=verbose,
incremental=True,
no_cache=no_cache,
)

summary_modules = prune_modules(modules, cfg, cfg.max_tokens_summary, render_summary)
summary = render_summary(summary_modules, cfg)
full = None if no_full else render_full(
prune_modules(modules, cfg, cfg.max_tokens_full, render_full), cfg
)
mini = None if no_mini else render_mini(modules, cfg)

write_outputs(cfg, summary, full=full, mini=mini)

elapsed = time.time() - start_time
timestamp = datetime.now().strftime("%H:%M:%S")
typer.echo(f"[{timestamp}] Rebuilt in {elapsed:.1f}s ({len(changes)} files changed)")
except KeyboardInterrupt:
typer.echo("\nWatching stopped.")
raise typer.Exit(code=0) from None


@app.command()
def validate(
Expand Down
26 changes: 26 additions & 0 deletions src/llmstxt_gen/watcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from __future__ import annotations

from collections.abc import Iterator
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
pass

try:
from watchfiles import watch
except ImportError:
watch = None


def iter_changes(root: Path) -> Iterator[set[str]]:
"""Yield on each batch of file changes under root."""
if watch is None:
raise ImportError(
"The 'watchfiles' package is required for watch mode. "
"Install it with: pip install llmstxt-gen[watch]"
)

for changes in watch(root):
# changes is a set of tuples (ChangeType, path)
yield {str(Path(c[1]).relative_to(root)) for c in changes}
59 changes: 59 additions & 0 deletions tests/test_watcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from pathlib import Path
from unittest.mock import patch

from typer.testing import CliRunner

from llmstxt_gen.cli import app
from llmstxt_gen.parsers.base import ParsedModule

runner = CliRunner()


def test_watch_and_diff_incompatible(tmp_path):
"""Test that --watch and --diff cannot be used together."""
result = runner.invoke(app, ["generate", str(tmp_path), "--watch", "--diff", "HEAD"])
assert result.exit_code == 1
assert "Error: --watch and --diff cannot be used together." in result.output


@patch("llmstxt_gen.cli.iter_changes")
@patch("llmstxt_gen.cli.write_outputs")
@patch("llmstxt_gen.cli._collect_modules")
def test_generate_watch_loop(mock_collect, mock_write, mock_iter, tmp_path):
"""Test the watch loop in the generate command."""
# Create a dummy file to parse
(tmp_path / "test.py").write_text("def hello(): pass")

# Mock iter_changes to yield once then stop
mock_iter.return_value = iter([{"test.py"}])

# Mock _collect_modules to return something
mock_module = ParsedModule(name="test", path="test.py", language="python")
mock_collect.return_value = [mock_module]

# Mock write_outputs to return paths
mock_write.return_value = [Path("llms.txt")]

result = runner.invoke(app, ["generate", str(tmp_path), "--watch"])

assert result.exit_code == 0
assert "Watching for changes" in result.output
assert "Rebuilt in" in result.output

# Initial call + 1 from watch loop
assert mock_collect.call_count == 2
assert mock_write.call_count == 2


@patch("llmstxt_gen.cli.iter_changes")
@patch("llmstxt_gen.cli._collect_modules")
def test_generate_watch_keyboard_interrupt(mock_collect, mock_iter, tmp_path):
"""Test that KeyboardInterrupt exits gracefully."""
# Mock _collect_modules to avoid early exit if no files found
mock_collect.return_value = [ParsedModule(name="test", path="test.py", language="python")]
mock_iter.side_effect = KeyboardInterrupt()

result = runner.invoke(app, ["generate", str(tmp_path), "--watch"])

assert result.exit_code == 0
assert "Watching stopped." in result.output
Loading
Loading