From 132f8bb42e77e65f1828e1e0ee3f8bb7f0eb0357 Mon Sep 17 00:00:00 2001 From: dos Date: Sun, 26 Jul 2026 11:36:53 +0900 Subject: [PATCH] feat: add --branches main to drop rewound-and-abandoned forks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewinding a Claude Code session (/rewind, or editing an earlier prompt) does not delete anything. It appends a message whose parentUuid points at an earlier message, so the transcript becomes a tree and every abandoned attempt stays in the JSONL. claude-code-log already models this correctly and renders each fork as its own "Branch • " section, which is the right default: nothing is hidden from the reader. For reading back a long working session, though, it is noise. On a real 4 MB, 1451-line transcript the abandoned forks are only 3% of the bytes but 11 of 43 user prompts — one in four questions the reader scrolls past was rewound away and never answered. Combined with the repeated section headers, the same question can appear three times. Anthropic does not mark dead branches: isSidechain stays False on every node in a rewound tree (anthropics/claude-code#24471), so there is no flag to filter on. The shape of the graph is the only signal available. select_main_lines() 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 never compete: subagent transcripts are real work branching off the trunk, and are kept whenever their anchor survives. Longest path, not latest timestamp. In the sample transcript the newest leaf is a stray attachment whose path is 12 entries; the thread the session actually spent its time on is 975. Selecting by recency keeps the stub and discards the work. The filter runs at the entry level, after dedup and before rendering, so the DAG the renderer builds is already linear and every existing branch code path naturally emits nothing. No renderer changes were needed. Default is --branches all, so existing output is byte-identical. Provider-wholesale rendering (Codex et al.) is untouched: the rewind-fork shape is specific to Claude Code's session format. Verified on the sample transcript with --detail minimal --compact --no-recaps --branches main branch sections 11 -> 0, user prompts 24 -> 18, 105 KB -> 101 KB --- claude_code_log/cli.py | 26 ++++++ claude_code_log/converter.py | 31 ++++++- claude_code_log/dag.py | 167 +++++++++++++++++++++++++++++++++++ test/test_branch_pruning.py | 159 +++++++++++++++++++++++++++++++++ 4 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 test/test_branch_pruning.py 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)