-
Notifications
You must be signed in to change notification settings - Fork 94
feat: add --branches main to drop rewound-and-abandoned forks #300
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Comment on lines
+1298
to
+1313
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Find where SummaryTranscriptEntry.leafUuid is consumed downstream.
rg -n -C3 '\.leafUuid\b' claude_code_log --type=pyRepository: daaain/claude-code-log Length of output: 2423 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== dag.py relevant sections =="
sed -n '1200,1330p' claude_code_log/dag.py
echo
echo "== converter.py relevant sections =="
sed -n '980,1125p' claude_code_log/converter.py
echo
echo "== renderer.py summary mapping =="
sed -n '1110,1165p' claude_code_log/renderer.py
echo
echo "== cache.py summary handling =="
sed -n '560,605p' claude_code_log/cache.py
echo
echo "== root-level classes =="
rg -n --type=py 'class SummaryTranscriptEntry|class TranscriptEntry|def deduplicate_messages|def filter_to_main_line|def main_line_uuids' claude_code_logRepository: daaain/claude-code-log Length of output: 16532 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== filter_to_main_line call sites =="
rg -n 'filter_to_main_line\(' claude_code_log -g '*.py'
echo
echo "== converter.py summary construction =="
rg -n -C4 'SummaryTranscriptEntry\(|leafUuid|summary' claude_code_log/converter.py
echo
echo "== cache usage of _leaf_uuid and cache entry order/build =="
rg -n -C3 '_leaf_uuid|_deserialize_entry|serialize_entry|prepare_session_summaries|deduplicate_messages|filter_to_main_line' claude_code_log -g '*.py'
echo
echo "== deterministic probe outline =="
python3 - <<'PY'
from pathlib import Path
for p in Path("claude_code_log").glob("*"):
if p.suffix not in {".py",".pyi"}: continue
text=p.read_text(errors="ignore")
print(f"{p}: leafUuid occurrences={text.count('leafUuid')}, summaries occurrences={text.count('summary')}")
PYRepository: daaain/claude-code-log Length of output: 26366 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== converter.py around summary generation =="
sed -n '2008,2100p' claude_code_log/converter.py
echo
echo "== converter.py around per-session branch==main summaries =="
sed -n '2378,2430p' claude_code_log/converter.py
echo
echo "== renderer.py around summary collection and first text =="
sed -n '730,790p' claude_code_log/renderer.py
sed -n '1112,1150p' claude_code_log/renderer.py
echo
echo "== compute_session_data flow summary =="
python3 - <<'PY'
from pathlib import Path
p=Path("claude_code_log/converter.py")
s=p.read_text()
for name in ["def get_summary_messages","def _collect_project_sessions","def compute_session_data","def _collect_project_sessions"]:
idx=s.find(f"def {name}")
print(f"{name}: {idx}")
PYRepository: daaain/claude-code-log Length of output: 11043 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== build_session_data / summary messages area =="
sed -n '2028,2065p' claude_code_log/converter.py
echo
echo "== renderer.py summary messages first_user text collection area =="
sed -n '1025,1095p' claude_code_log/renderer.py
sed -n '1096,1112p' claude_code_log/renderer.py
echo
echo "== summary output summary messages area in model serialization/collector =="
rg -n -C4 'summary_messages|SessionSummary|session_summaries\(|first_user_message|title or first_user' claude_code_log/cache.py claude_code_log/renderer.py claude_code_log/converter.py -g '*.py'
echo
echo "== exact call order in renderer.py =="
sed -n '740,770p'claude_code_log/renderer.pyRepository: daaain/claude-code-log Length of output: 32050 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== renderer.py summary messages construction =="
sed -n '1035,1090p' claude_code_log/renderer.py
echo
echo "== models.py SummaryTranscriptEntry =="
sed -n '265,285p' claude_code_log/models.py
echo
echo "== summary text consumers and first_user fallback only area =="
python3 - <<'PY'
from pathlib import Path
import re
for p in Path("claude_code_log").glob("*.py"):
s=p.read_text(errors="ignore")
hits=[]
for n,line in enumerate(s.splitlines(),1):
if "first_user_message" in line or "prepare_session_summaries" in line:
hits.append((n,line.rstrip()))
if hits:
print(f"\n--- {p} ---")
for n,line in hits[:80]:
print(f"{n}: {line}")
PYRepository: daaain/claude-code-log Length of output: 6417 Update After 🤖 Prompt for AI Agents |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Branch pruning runs before dedup here, unlike
convert_jsonl_to.The comment claims this mirrors
convert_jsonl_to, butconvert_jsonl_toexplicitly dedups the full message list before callingfilter_to_main_line("Runs after dedup so the DAG sees the same entries the renderer will", Line 2057). Here,filter_to_main_line(messages)runs on the raw, un-deduplicated multi-session list;deduplicate_messagesonly runs later (Line 2715) on the already-narrowedsession_messagessubset.deduplicate_messages's own docstring documents real duplicate shapes (version-stutter, same-timestamp "branch switch artifacts") that the DAG builder could misread as competing fork branches before dedup collapses them — so a session containing such duplicates could get pruned differently here than inconvert_jsonl_to, defeating the stated "matches the combined transcript" goal.🐛 Proposed fix — dedup before pruning, mirroring convert_jsonl_to
# Load messages from JSONL files messages, _session_tree = load_directory_transcripts(input_path, cache_manager) + messages = deduplicate_messages(messages) + # 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)(and drop the now-redundant
deduplicate_messages(session_messages)call below, or leave it as a cheap no-op safety net.)📝 Committable suggestion
🤖 Prompt for AI Agents