diff --git a/claude_code_log/cli.py b/claude_code_log/cli.py index a5660af8..59a0845a 100644 --- a/claude_code_log/cli.py +++ b/claude_code_log/cli.py @@ -101,6 +101,7 @@ def _render_provider_input_file( compact: bool, no_timestamps: bool, no_recaps: bool, + branches: str, open_browser: bool, ) -> None: """Render a single provider session file handed in as an INPUT_PATH. @@ -204,6 +205,7 @@ def _run_provider_wholesale( compact: bool, no_timestamps: bool, no_recaps: bool, + branches: str, write_combined: bool, write_individual: bool, from_date: "Optional[str]", @@ -1033,6 +1035,20 @@ def _validate_git_link_template(template: str) -> None: "redundancy at --depth assistant." ), ) +@click.option( + "--branches", + type=click.Choice(["all", "main"]), + default="all", + show_default=True, + help=( + "Which conversation branches to render. Rewinding (/rewind, or " + "editing an earlier prompt) forks the transcript instead of " + "deleting anything, so abandoned attempts stay in the JSONL and " + "are rendered as extra 'Branch' sections. 'main' keeps only the " + "longest root-to-leaf path per trunk, dropping the attempts that " + "were rewound away." + ), +) @click.option( "--debug", is_flag=True, @@ -1069,6 +1085,7 @@ def main( git_link: Optional[str], no_timestamps: bool, no_recaps: bool, + branches: str, debug: bool, ) -> None: """Convert Claude transcript JSONL files to HTML or Markdown. @@ -1384,6 +1401,7 @@ def main( compact, no_timestamps, no_recaps, + branches, write_combined, write_individual, from_date, @@ -1411,6 +1429,7 @@ def main( compact, no_timestamps, no_recaps, + branches, open_browser, ) return @@ -1659,6 +1678,7 @@ def render_provider(destination: Path) -> Path: compact=compact, no_timestamps=no_timestamps, no_recaps=no_recaps, + branches=branches, ), ) return @@ -1674,6 +1694,7 @@ def render_provider(destination: Path) -> Path: compact=compact, no_timestamps=no_timestamps, no_recaps=no_recaps, + branches=branches, ) click.echo(f"Successfully exported session to {output_path}") if open_browser: @@ -1735,6 +1756,7 @@ def render_provider(destination: Path) -> Path: write_combined=write_combined, no_timestamps=no_timestamps, no_recaps=no_recaps, + branches=branches, jobs=jobs, ) @@ -1775,6 +1797,7 @@ def render_provider(destination: Path) -> Path: compact, no_timestamps, no_recaps, + branches, write_combined, write_individual, from_date, @@ -1797,6 +1820,7 @@ def render_provider(destination: Path) -> Path: compact, no_timestamps, no_recaps, + branches, open_browser, ) return @@ -1854,6 +1878,7 @@ def render_provider(destination: Path) -> Path: write_combined=True, no_timestamps=no_timestamps, no_recaps=no_recaps, + branches=branches, force_regenerate=True, ), ) @@ -1883,6 +1908,7 @@ def render_provider(destination: Path) -> Path: write_combined=write_combined, no_timestamps=no_timestamps, no_recaps=no_recaps, + branches=branches, # An explicit `-o` *file* always regenerates: the version-marker # skip only knows the embedded version, not which source produced # the file, so it would keep stale content at a user-chosen path diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index 2f9efc1e..ab489ccd 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -60,7 +60,12 @@ ToolResultContent, ToolUseContent, ) -from .dag import SessionTree, build_dag_from_entries, traverse_session_tree +from .dag import ( + SessionTree, + build_dag_from_entries, + filter_to_main_line, + traverse_session_tree, +) from .renderer import ( get_renderer, prepare_session_ai_titles, @@ -1877,6 +1882,7 @@ def convert_jsonl_to( write_combined: bool = True, no_timestamps: bool = False, no_recaps: bool = False, + branches: str = "all", force_regenerate: bool = False, report: Optional["RegenerationReport"] = None, ) -> Path: @@ -1895,6 +1901,9 @@ def convert_jsonl_to( page_size: Maximum messages per page for combined transcript pagination. If None, uses format default (embedded for HTML, referenced for Markdown). depth: Output depth level (full, high, low, minimal). + branches: Which conversation branches to render. "all" (default) + keeps every rewound fork; "main" keeps only the longest + root→leaf path per trunk, dropping abandoned rewind attempts. force_regenerate: Always (re)generate, bypassing the version-marker staleness skip. The CLI sets this for an explicit ``--output`` (issue #221): the staleness heuristic only knows the embedded @@ -2044,6 +2053,18 @@ def convert_jsonl_to( # Deduplicate messages (removes version stutters while preserving concurrent tool results) messages = deduplicate_messages(messages) + # Drop rewound-and-abandoned fork branches (--branches main). Runs after + # dedup so the DAG sees the same entries the renderer will. A prebuilt + # tree describes the unfiltered graph, so rebuild it from the survivors + # while carrying the workflow links across. + if branches == "main": + messages = filter_to_main_line(messages) + if session_tree is not None: + _pruned_tree = build_dag_from_entries(messages) + _pruned_tree.workflow_runs = session_tree.workflow_runs + _pruned_tree.workflow_links = session_tree.workflow_links + session_tree = _pruned_tree + # Update title to include date range if specified if from_date or to_date: date_range_parts: list[str] = [] @@ -2609,6 +2630,7 @@ def generate_single_session_file( compact: bool = False, no_timestamps: bool = False, no_recaps: bool = False, + branches: str = "all", ) -> Path: """Generate a single session output file for the given session ID. @@ -2646,6 +2668,11 @@ def generate_single_session_file( # Load messages from JSONL files messages, _session_tree = load_directory_transcripts(input_path, cache_manager) + # Mirror ``convert_jsonl_to``: drop rewound-and-abandoned fork branches + # so a per-session file matches the combined transcript (--branches main). + if branches == "main": + messages = filter_to_main_line(messages) + # Collect all known session IDs: from loaded messages + cache metadata all_session_ids: set[str] = { getattr(msg, "sessionId") @@ -3435,6 +3462,7 @@ def process_projects_hierarchy( write_combined: bool = True, no_timestamps: bool = False, no_recaps: bool = False, + branches: str = "all", jobs: Optional[int] = None, ) -> Path: """Process the entire ~/.claude/projects/ hierarchy and create linked output files. @@ -3649,6 +3677,7 @@ def _convert_plan_inline(plan: _ProjectPlan) -> None: write_combined=write_combined, no_timestamps=no_timestamps, no_recaps=no_recaps, + branches=branches, ) except Exception: _print_project_failed(plan, traceback.format_exc()) diff --git a/claude_code_log/dag.py b/claude_code_log/dag.py index 1d5dbac8..cfbee332 100644 --- a/claude_code_log/dag.py +++ b/claude_code_log/dag.py @@ -1144,3 +1144,170 @@ def build_dag_from_entries( build_dag(nodes, sidechain_uuids=sidechain_uuids) sessions = extract_session_dag_lines(nodes) return build_session_tree(nodes, sessions) + + +# ============================================================================= +# Step 6: Abandoned-branch pruning (--branches main) +# ============================================================================= + + +def _line_group_key(dag_line: SessionDAGLine) -> str: + """Group a DAG-line with the trunk it forked from. + + Within-session forks get synthetic ids (``s1@abcd1234``) and carry the + trunk id in ``original_session_id``; the trunk itself carries none. + """ + return dag_line.original_session_id or dag_line.session_id + + +def _path_length( + line_id: str, + sessions: dict[str, SessionDAGLine], + memo: dict[str, int], +) -> int: + """Messages from the conversation root through the end of ``line_id``. + + A fork does not restate the history it grew from, so a branch's true + length is its parent's length *up to the fork point* plus its own. + """ + if line_id in memo: + return memo[line_id] + memo[line_id] = 0 # cycle guard + dag_line = sessions[line_id] + prefix = 0 + parent_id = dag_line.parent_session_id + if dag_line.is_branch and parent_id is not None and parent_id in sessions: + parent = sessions[parent_id] + if dag_line.attachment_uuid in parent.uuids: + prefix = parent.uuids.index(dag_line.attachment_uuid) + 1 + # The fork point's own history counts, but only once. + prefix += _path_length(parent_id, sessions, memo) - len(parent.uuids) + total = prefix + len(dag_line.uuids) + memo[line_id] = total + return total + + +def select_main_lines( + sessions: dict[str, SessionDAGLine], +) -> dict[str, SessionDAGLine]: + """Drop within-session fork branches that were rewound and abandoned. + + Claude Code's ``/rewind`` (and editing an earlier prompt) does not delete + anything: it appends a new message whose ``parentUuid`` points at an + *earlier* message, so the transcript becomes a tree and the abandoned + attempt stays in the file. Anthropic never marks those dead branches — + ``isSidechain`` stays ``False`` on every node (anthropics/claude-code#24471) + — so they cannot be filtered by flag; the shape of the graph is the only + signal available. + + This keeps, per trunk, the single root→leaf path carrying the most + messages, and truncates each ancestor at the fork point it was left at. + Sidechains (subagent transcripts) never compete: they are real work + branching off the trunk, and are kept whenever their anchor survives. + + Longest path — not latest timestamp — is deliberate. The newest leaf in a + real transcript is routinely a stray ``attachment`` or a one-line prompt + typed after a rewind, so "most recent" selects a stub. Longest path picks + the thread the session actually spent its time on. + """ + groups: dict[str, list[str]] = {} + for line_id, dag_line in sessions.items(): + if dag_line.is_sidechain: + continue + groups.setdefault(_line_group_key(dag_line), []).append(line_id) + + memo: dict[str, int] = {} + kept: dict[str, SessionDAGLine] = {} + + for group_lines in groups.values(): + if len(group_lines) == 1: + kept[group_lines[0]] = sessions[group_lines[0]] + continue + winner = max( + group_lines, + key=lambda lid: ( + _path_length(lid, sessions, memo), + sessions[lid].first_timestamp, + ), + ) + # Walk the winner back to the trunk, truncating each ancestor at + # the fork point its surviving child grew from. + chain: list[str] = [] + cursor: Optional[str] = winner + guard: set[str] = set() + while cursor is not None and cursor not in guard: + guard.add(cursor) + chain.append(cursor) + current = sessions[cursor] + if not current.is_branch: + break + cursor = current.parent_session_id + if cursor is not None and cursor not in sessions: + cursor = None + chain.reverse() + + for index, line_id in enumerate(chain): + dag_line = sessions[line_id] + child_id = chain[index + 1] if index + 1 < len(chain) else None + if child_id is None: + kept[line_id] = dag_line + continue + fork_uuid = sessions[child_id].attachment_uuid + if fork_uuid is None or fork_uuid not in dag_line.uuids: + kept[line_id] = dag_line + continue + cut = dag_line.uuids.index(fork_uuid) + 1 + kept[line_id] = SessionDAGLine( + session_id=dag_line.session_id, + uuids=dag_line.uuids[:cut], + first_timestamp=dag_line.first_timestamp, + parent_session_id=dag_line.parent_session_id, + attachment_uuid=dag_line.attachment_uuid, + is_branch=dag_line.is_branch, + original_session_id=dag_line.original_session_id, + is_sidechain=dag_line.is_sidechain, + ) + + # Re-admit sidechains whose anchor message survived the prune. + surviving_uuids = {uuid for dl in kept.values() for uuid in dl.uuids} + for line_id, dag_line in sessions.items(): + if not dag_line.is_sidechain: + continue + anchor = dag_line.attachment_uuid + if anchor is None or anchor in surviving_uuids: + kept[line_id] = dag_line + + dropped = len(sessions) - len(kept) + if dropped: + logger.debug("Pruned %d abandoned fork branch(es)", dropped) + return kept + + +def main_line_uuids( + entries: list[TranscriptEntry], + sidechain_uuids: set[str] | None = None, +) -> set[str]: + """UUIDs surviving :func:`select_main_lines` for ``entries``.""" + nodes = build_message_index(entries) + build_dag(nodes, sidechain_uuids=sidechain_uuids) + sessions = extract_session_dag_lines(nodes) + kept = select_main_lines(sessions) + return {uuid for dag_line in kept.values() for uuid in dag_line.uuids} + + +def filter_to_main_line( + entries: list[TranscriptEntry], + sidechain_uuids: set[str] | None = None, +) -> list[TranscriptEntry]: + """Return ``entries`` with abandoned rewind branches removed. + + Entries without a ``uuid`` (summaries, ai-titles, queue operations) do + not participate in the DAG and are passed through untouched. + """ + keep = main_line_uuids(entries, sidechain_uuids=sidechain_uuids) + result: list[TranscriptEntry] = [] + for entry in entries: + uuid = getattr(entry, "uuid", None) + if uuid is None or uuid in keep: + result.append(entry) + return result diff --git a/test/test_branch_pruning.py b/test/test_branch_pruning.py new file mode 100644 index 00000000..ad2822fb --- /dev/null +++ b/test/test_branch_pruning.py @@ -0,0 +1,159 @@ +"""Tests for ``--branches main`` (abandoned rewind-fork pruning). + +Rewinding in Claude Code appends a message whose ``parentUuid`` points at an +earlier message rather than deleting anything, so the transcript is a tree and +abandoned attempts survive in the JSONL. See ``select_main_lines``. +""" + +from typing import Any + +from claude_code_log.dag import ( + build_dag, + build_message_index, + extract_session_dag_lines, + filter_to_main_line, + select_main_lines, +) +from claude_code_log.factories import create_transcript_entry +from claude_code_log.models import TranscriptEntry + + +def _user(uuid: str, parent: str | None, text: str, ts: str) -> dict[str, Any]: + return { + "type": "user", + "uuid": uuid, + "parentUuid": parent, + "timestamp": ts, + "sessionId": "s1", + "isSidechain": False, + "userType": "external", + "cwd": "/tmp", + "version": "2.0.0", + "message": {"role": "user", "content": text}, + } + + +def _assistant(uuid: str, parent: str, text: str, ts: str) -> dict[str, Any]: + return { + "type": "assistant", + "uuid": uuid, + "parentUuid": parent, + "timestamp": ts, + "sessionId": "s1", + "isSidechain": False, + "userType": "external", + "cwd": "/tmp", + "version": "2.0.0", + "message": { + "id": f"msg_{uuid}", + "type": "message", + "role": "assistant", + "model": "claude-opus-4", + "content": [{"type": "text", "text": text}], + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1, "service_tier": "standard"}, + }, + } + + +def _parse(rows: list[dict[str, Any]]) -> list[TranscriptEntry]: + return [create_transcript_entry(row) for row in rows] + + +def _rewound_session() -> list[TranscriptEntry]: + """u1 → a1 → (u2a → a2a | u2b → a2b → u3 → a3). + + ``u2a`` was rewound away: the user edited the prompt into ``u2b`` and the + session continued there. The abandoned attempt keeps its own reply. + """ + return _parse( + [ + _user("u1", None, "first question", "2026-07-25T10:00:00Z"), + _assistant("a1", "u1", "first answer", "2026-07-25T10:00:10Z"), + _user("u2a", "a1", "abandoned attempt", "2026-07-25T10:01:00Z"), + _assistant("a2a", "u2a", "abandoned reply", "2026-07-25T10:01:10Z"), + _user("u2b", "a1", "retyped question", "2026-07-25T10:02:00Z"), + _assistant("a2b", "u2b", "real answer", "2026-07-25T10:02:10Z"), + _user("u3", "a2b", "follow-up", "2026-07-25T10:03:00Z"), + _assistant("a3", "u3", "final answer", "2026-07-25T10:03:10Z"), + ] + ) + + +def _uuids(entries: list[TranscriptEntry]) -> set[str]: + return {u for e in entries if (u := getattr(e, "uuid", None)) is not None} + + +def _lines(entries: list[TranscriptEntry]): + nodes = build_message_index(entries) + build_dag(nodes) + return extract_session_dag_lines(nodes) + + +def test_abandoned_branch_is_dropped() -> None: + """The rewound attempt and its reply disappear; the longer path survives.""" + kept = _uuids(filter_to_main_line(_rewound_session())) + assert "u2a" not in kept + assert "a2a" not in kept + assert {"u1", "a1", "u2b", "a2b", "u3", "a3"} <= kept + + +def test_longest_path_wins_not_latest_timestamp() -> None: + """A stray prompt typed after the real work must not win the selection. + + The newest leaf in a real transcript is routinely a one-line afterthought; + selecting by recency would keep it and discard everything else. + """ + entries = _rewound_session() + entries.extend( + _parse([_user("stray", "a1", "oops", "2026-07-25T23:59:00Z")]) + ) + kept = _uuids(filter_to_main_line(entries)) + assert "stray" not in kept + assert {"u3", "a3"} <= kept + + +def test_all_branches_kept_without_pruning() -> None: + """Default behaviour is unchanged: every fork is still a DAG-line.""" + sessions = _lines(_rewound_session()) + assert sum(1 for line in sessions.values() if line.is_branch) >= 1 + total = sum(len(line.uuids) for line in sessions.values()) + assert total == 8 + + +def test_pruned_result_is_linear() -> None: + """After pruning there is nothing left to fork, so no branch lines remain.""" + pruned = filter_to_main_line(_rewound_session()) + sessions = _lines(pruned) + assert not any(line.is_branch for line in sessions.values()) + + +def test_ancestor_truncated_at_fork_point() -> None: + """The trunk keeps its history up to the fork and loses the dead tail.""" + sessions = _lines(_rewound_session()) + kept = select_main_lines(sessions) + surviving = {uuid for line in kept.values() for uuid in line.uuids} + assert "a1" in surviving # fork point itself is history, not a casualty + assert "a2a" not in surviving + + +def test_unforked_session_is_untouched() -> None: + """A transcript with no rewinds must pass through byte-identical.""" + entries = _parse( + [ + _user("u1", None, "question", "2026-07-25T10:00:00Z"), + _assistant("a1", "u1", "answer", "2026-07-25T10:00:10Z"), + ] + ) + assert filter_to_main_line(entries) == entries + + +def test_entries_without_uuid_pass_through() -> None: + """Summaries carry no uuid and never participate in the DAG.""" + entries = _rewound_session() + summary = create_transcript_entry( + {"type": "summary", "summary": "A session", "leafUuid": "a3"} + ) + entries.append(summary) + assert summary in filter_to_main_line(entries)