feat(snippets): record merge provenance on the survivor
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 38s
CI & Build / TypeScript typecheck (push) Successful in 40s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 1m7s

Merge kept the target's fields, unioned locations and tags, and trashed
the sources — recording nothing about what it absorbed. If a variant
handled an edge case the survivor doesn't, that difference left the
visible record entirely; recovering it meant knowing to go digging in
the trash.

The survivor now carries `merged_from`: a "**Merged from:** #2, #3" line
in the body for humans, and the same list in the `data` mirror for
queries, written from one value like every other field (#2087).

It accumulates rather than replaces — a target merged twice keeps both
histories — and skipped sources (cross-owner, per #231) are excluded, so
the record never claims to contain something it never absorbed.

Ordinary edits carry it forward. update_snippet recomposes body and
mirror from scratch, so an omission there would silently erase the
history on the next unrelated edit; that path is pinned by its own test,
including the pre-0070 case where the body line is the only copy.

Surfaced in the snippet detail view as a "Merged from" row — the view
renders parsed fields, not the raw body, so the body line alone would
have been invisible to the operator (rule #27).

Un-merge, the other half of #2087, stays open: restoring a source from
trash still doesn't strip its locations off the survivor, and what
partial un-merge should mean is a design question, not a coding one.
`merged_from` is the record that makes it tractable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
This commit is contained in:
2026-07-27 15:23:41 -04:00
parent 9fa474b3c4
commit 0d396de215
6 changed files with 249 additions and 7 deletions
+3
View File
@@ -20,6 +20,9 @@ export interface SnippetFields {
path: string; path: string;
symbol: string; symbol: string;
locations: SnippetLocation[]; locations: SnippetLocation[];
/** Ids of the snippets folded into this one by merge, oldest first. Read-only:
* merge is the only thing that adds to it, and an edit carries it forward. */
merged_from: number[];
code: string; code: string;
} }
+23
View File
@@ -23,6 +23,10 @@ const canWrite = computed(() => {
const locations = computed(() => snippet.value?.snippet.locations ?? []); const locations = computed(() => snippet.value?.snippet.locations ?? []);
// What this record absorbed. Merge keeps the target's fields and trashes the
// sources, so this line is the only visible trace that the variants existed.
const mergedFrom = computed(() => snippet.value?.snippet.merged_from ?? []);
function locParts(loc: { repo: string; path: string; symbol: string }): string[] { function locParts(loc: { repo: string; path: string; symbol: string }): string[] {
return [loc.repo, loc.path, loc.symbol].filter((p) => p && p.trim()); return [loc.repo, loc.path, loc.symbol].filter((p) => p && p.trim());
} }
@@ -110,6 +114,15 @@ async function confirmDelete() {
<dt>Language</dt> <dt>Language</dt>
<dd>{{ snippet.snippet.language }}</dd> <dd>{{ snippet.snippet.language }}</dd>
</template> </template>
<template v-if="mergedFrom.length">
<dt>Merged from</dt>
<dd class="merged-from">
<span v-for="mid in mergedFrom" :key="mid">#{{ mid }}</span>
<span class="merged-hint">
folded in here the originals are in the trash
</span>
</dd>
</template>
</dl> </dl>
<div class="code-block"> <div class="code-block">
@@ -274,6 +287,16 @@ async function confirmDelete() {
gap: 0.35rem; gap: 0.35rem;
align-items: center; align-items: center;
} }
.merged-from {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: baseline;
}
.merged-hint {
color: var(--color-text-muted);
font-size: 0.8rem;
}
.code-block { .code-block {
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
+4
View File
@@ -243,6 +243,10 @@ async def merge_snippets(target_id: int, source_ids: list[int]) -> dict:
trash (recoverable). The survivor is re-embedded so recall stops surfacing the trash (recoverable). The survivor is re-embedded so recall stops surfacing the
now-merged duplicates. now-merged duplicates.
The survivor records what it absorbed as `merged_from` (in its `snippet`
fields and as a "Merged from: #ids" line in the body), so a variant that got
folded in leaves a trace outside the trash. It accumulates across merges.
Args: Args:
target_id: The snippet to keep (the canonical record). target_id: The snippet to keep (the canonical record).
source_ids: Snippet ids to fold into the target and retire. Ids that source_ids: Snippet ids to fold into the target and retire. Ids that
+66 -4
View File
@@ -120,6 +120,23 @@ def _render_location_block(locations: list[dict]) -> str | None:
return f"**Locations:**\n{lines}" return f"**Locations:**\n{lines}"
def _normalize_merged_from(ids: list[int] | None) -> list[int]:
"""Merge provenance as a clean id list: ints only, no dups, order kept.
Order is history, not sorting — earlier merges stay first, so the list reads
as the sequence of things folded in.
"""
out: list[int] = []
for raw in ids or []:
try:
i = int(raw)
except (TypeError, ValueError):
continue
if i > 0 and i not in out:
out.append(i)
return out
def compose_body( def compose_body(
*, *,
code: str, code: str,
@@ -130,6 +147,7 @@ def compose_body(
path: str = "", path: str = "",
symbol: str = "", symbol: str = "",
locations: list[dict] | None = None, locations: list[dict] | None = None,
merged_from: list[int] | None = None,
) -> str: ) -> str:
"""Render structured fields into the snippet body markdown. Empty fields are """Render structured fields into the snippet body markdown. Empty fields are
omitted so the body stays clean. omitted so the body stays clean.
@@ -151,6 +169,14 @@ def compose_body(
loc_block = _render_location_block(locs) loc_block = _render_location_block(locs)
if loc_block: if loc_block:
header.append(loc_block) header.append(loc_block)
merged = _normalize_merged_from(merged_from)
if merged:
# Human-readable mirror of data["merged_from"]. Without it, a merge folds
# variants in and the record of what was absorbed lives only in the trash
# — recoverable only by someone who already knows to go looking.
header.append(
"**Merged from:** " + ", ".join(f"#{i}" for i in merged)
)
fence_lang = (language or "").strip().lower() fence_lang = (language or "").strip().lower()
code_block = f"```{fence_lang}\n{(code or '').rstrip()}\n```" code_block = f"```{fence_lang}\n{(code or '').rstrip()}\n```"
if header: if header:
@@ -166,7 +192,9 @@ _LOC_RE = re.compile(r"^\*\*Location:\*\*\s*(.+?)\s*$", re.MULTILINE)
_LOCS_RE = re.compile( _LOCS_RE = re.compile(
r"^\*\*Locations:\*\*[ \t]*\n((?:[ \t]*-[ \t]*.+\n?)+)", re.MULTILINE r"^\*\*Locations:\*\*[ \t]*\n((?:[ \t]*-[ \t]*.+\n?)+)", re.MULTILINE
) )
_MERGED_RE = re.compile(r"^\*\*Merged from:\*\*\s*(.+?)\s*$", re.MULTILINE)
_CODE_RE = re.compile(r"```([\w+.#-]*)\n(.*?)\n```", re.DOTALL) _CODE_RE = re.compile(r"```([\w+.#-]*)\n(.*?)\n```", re.DOTALL)
_ID_RE = re.compile(r"#(\d+)")
def _parse_location_str(s: str) -> dict | None: def _parse_location_str(s: str) -> dict | None:
@@ -202,6 +230,7 @@ def parse_snippet_fields(
"path": "", "path": "",
"symbol": "", "symbol": "",
"locations": [], "locations": [],
"merged_from": [],
"code": "", "code": "",
} }
@@ -235,6 +264,12 @@ def parse_snippet_fields(
fields["path"] = locations[0]["path"] fields["path"] = locations[0]["path"]
fields["symbol"] = locations[0]["symbol"] fields["symbol"] = locations[0]["symbol"]
m = _MERGED_RE.search(body)
if m:
fields["merged_from"] = _normalize_merged_from(
[int(i) for i in _ID_RE.findall(m.group(1))]
)
m = _CODE_RE.search(body) m = _CODE_RE.search(body)
if m: if m:
fields["language"] = m.group(1).strip() fields["language"] = m.group(1).strip()
@@ -254,7 +289,9 @@ def parse_snippet_fields(
# Fields kept in `data`. Code is deliberately absent: the body already holds it, # Fields kept in `data`. Code is deliberately absent: the body already holds it,
# and copying a blob into the column we index *around* would be pure weight. # and copying a blob into the column we index *around* would be pure weight.
_DATA_FIELDS = ("name", "when_to_use", "signature", "language", "locations") _DATA_FIELDS = (
"name", "when_to_use", "signature", "language", "locations", "merged_from",
)
def compose_data( def compose_data(
@@ -264,6 +301,7 @@ def compose_data(
signature: str = "", signature: str = "",
language: str = "", language: str = "",
locations: list[dict] | None = None, locations: list[dict] | None = None,
merged_from: list[int] | None = None,
) -> dict: ) -> dict:
"""Build the `notes.data` mirror of a snippet's structured fields. """Build the `notes.data` mirror of a snippet's structured fields.
@@ -284,6 +322,9 @@ def compose_data(
locs = _normalize_locations(locations) locs = _normalize_locations(locations)
if locs: if locs:
out["locations"] = locs out["locations"] = locs
merged = _normalize_merged_from(merged_from)
if merged:
out["merged_from"] = merged
return out return out
@@ -478,6 +519,9 @@ async def update_snippet(
code=merged["code"], language=merged["language"], code=merged["code"], language=merged["language"],
signature=merged["signature"], when_to_use=merged["when_to_use"], signature=merged["signature"], when_to_use=merged["when_to_use"],
locations=merged_locations, locations=merged_locations,
# Carried, never set here: an ordinary edit must not erase the record
# of what was folded in, and only a merge may add to it.
merged_from=merged.get("merged_from"),
), ),
# Re-derived from the same merged field set as the body, so an edit can't # Re-derived from the same merged field set as the body, so an edit can't
# leave the indexed mirror describing the previous version. # leave the indexed mirror describing the previous version.
@@ -485,6 +529,7 @@ async def update_snippet(
name=merged["name"], when_to_use=merged["when_to_use"], name=merged["name"], when_to_use=merged["when_to_use"],
signature=merged["signature"], language=merged["language"], signature=merged["signature"], language=merged["language"],
locations=merged_locations, locations=merged_locations,
merged_from=merged.get("merged_from"),
), ),
} }
# Recompute tags: keep any non-language, non-marker tags the note already had # Recompute tags: keep any non-language, non-marker tags the note already had
@@ -566,8 +611,16 @@ async def merge_snippets(user_id: int, target_id: int, source_ids: list[int]):
cross-owner merge stays out of scope (#231) — and must itself be writable; cross-owner merge stays out of scope (#231) — and must itself be writable;
sources failing either test are skipped rather than silently half-merged. sources failing either test are skipped rather than silently half-merged.
The survivor records what it absorbed as `merged_from` — in the `data` mirror
and as a `**Merged from:** #ids` line in the body, written from one value like
every other field. It accumulates across merges and ordinary edits carry it
forward; only a merge adds to it.
Returns (merged_target_note, merged_source_ids), or None if the target isn't Returns (merged_target_note, merged_source_ids), or None if the target isn't
a snippet the caller can see.""" a snippet the caller can see. Note the returned ids are the sources actually
TRASHED, while `merged_from` is everything folded in — they differ only if a
source vanished between the field union and the trash call, which leaves the
source visible rather than losing anything."""
from scribe.services.access import can_write_note from scribe.services.access import can_write_note
target = await get_snippet(user_id, target_id) target = await get_snippet(user_id, target_id)
if target is None: if target is None:
@@ -596,13 +649,22 @@ async def merge_snippets(user_id: int, target_id: int, source_ids: list[int]):
parsed_sources = [(snippet_fields(s), s.tags) for s in sources] parsed_sources = [(snippet_fields(s), s.tags) for s in sources]
locations, extra_tags = merge_snippet_fields(tgt_fields, target.tags, parsed_sources) locations, extra_tags = merge_snippet_fields(tgt_fields, target.tags, parsed_sources)
# Provenance: what this record absorbed, and when it absorbed it, in order.
# Merge keeps the target's scalar fields and trashes the sources, so without
# this the fact that a variant ever existed survives only in the trash — and
# only for someone who already knew to go looking. Accumulated, not replaced:
# a target merged twice keeps both histories.
merged_from = _normalize_merged_from(
list(tgt_fields.get("merged_from") or []) + [s.id for s in sources]
)
# Owner-scoped write, authorised above — same reason as update_snippet. # Owner-scoped write, authorised above — same reason as update_snippet.
updated = await notes_svc.update_note( updated = await notes_svc.update_note(
owner_id, target_id, owner_id, target_id,
body=compose_body( body=compose_body(
code=tgt_fields["code"], language=tgt_fields["language"], code=tgt_fields["code"], language=tgt_fields["language"],
signature=tgt_fields["signature"], when_to_use=tgt_fields["when_to_use"], signature=tgt_fields["signature"], when_to_use=tgt_fields["when_to_use"],
locations=locations, locations=locations, merged_from=merged_from,
), ),
tags=compose_tags(tgt_fields["language"], extra_tags), tags=compose_tags(tgt_fields["language"], extra_tags),
# The survivor's location set grew, so its mirror has to grow with it — # The survivor's location set grew, so its mirror has to grow with it —
@@ -611,7 +673,7 @@ async def merge_snippets(user_id: int, target_id: int, source_ids: list[int]):
data=compose_data( data=compose_data(
name=tgt_fields["name"], when_to_use=tgt_fields["when_to_use"], name=tgt_fields["name"], when_to_use=tgt_fields["when_to_use"],
signature=tgt_fields["signature"], language=tgt_fields["language"], signature=tgt_fields["signature"], language=tgt_fields["language"],
locations=locations, locations=locations, merged_from=merged_from,
), ),
) )
if updated is None: if updated is None:
+40 -3
View File
@@ -235,20 +235,57 @@ def test_data_and_body_round_trip_to_the_same_fields():
# serializers get their own argument lists rather than a shared spread. # serializers get their own argument lists rather than a shared spread.
title = s.compose_title(name, when) title = s.compose_title(name, when)
body = s.compose_body(code="const x = 1", language=lang, signature=sig, body = s.compose_body(code="const x = 1", language=lang, signature=sig,
when_to_use=when, locations=locs) when_to_use=when, locations=locs, merged_from=[41, 42])
tags = s.compose_tags(lang) tags = s.compose_tags(lang)
from_body = s.snippet_fields(_Note(title=title, body=body, tags=tags)) from_body = s.snippet_fields(_Note(title=title, body=body, tags=tags))
from_data = s.snippet_fields(_Note( from_data = s.snippet_fields(_Note(
title=title, body=body, tags=tags, title=title, body=body, tags=tags,
data=s.compose_data(name=name, when_to_use=when, signature=sig, data=s.compose_data(name=name, when_to_use=when, signature=sig,
language=lang, locations=locs), language=lang, locations=locs, merged_from=[41, 42]),
)) ))
for key in ("name", "when_to_use", "signature", "language", "locations", for key in ("name", "when_to_use", "signature", "language", "locations",
"repo", "path", "symbol", "code"): "merged_from", "repo", "path", "symbol", "code"):
assert from_body[key] == from_data[key], key assert from_body[key] == from_data[key], key
# --- merge provenance (#2087) ------------------------------------------------
def test_normalize_merged_from_keeps_history_order_and_drops_junk():
"""Order is history, not sorting — earlier merges stay first."""
assert s._normalize_merged_from([9, 3, 9, "4", None, 0, -2, "x"]) == [9, 3, 4]
assert s._normalize_merged_from(None) == []
def test_compose_body_renders_merged_from_and_parse_reads_it_back():
body = s.compose_body(code="x = 1", merged_from=[12, 13])
assert "**Merged from:** #12, #13" in body
got = s.parse_snippet_fields("n — u", body, None)
assert got["merged_from"] == [12, 13]
# The code fence still comes last — provenance is header metadata.
assert body.rstrip().endswith("```")
def test_compose_body_omits_merged_from_when_there_is_none():
"""A snippet that was never merged says nothing about merging."""
assert "Merged from" not in s.compose_body(code="x = 1")
assert s.parse_snippet_fields("n — u", "```\nx = 1\n```", None)["merged_from"] == []
def test_compose_data_mirrors_merged_from():
assert s.compose_data(name="f", merged_from=[3, 3, 2])["merged_from"] == [3, 2]
assert "merged_from" not in s.compose_data(name="f")
def test_snippet_fields_prefers_the_data_columns_merged_from():
"""Same rule as every other mirrored field: `data` wins when it's populated,
so a hand-edited body can't silently rewrite the merge history."""
body = s.compose_body(code="x = 1", merged_from=[12])
note = _Note(title="n — u", body=body, tags=["snippet"],
data=s.compose_data(name="n", merged_from=[12, 13]))
assert s.snippet_fields(note)["merged_from"] == [12, 13]
def test_snippet_to_dict_includes_parsed_fields(): def test_snippet_to_dict_includes_parsed_fields():
class FakeNote: class FakeNote:
title = "debounce — rate-limit" title = "debounce — rate-limit"
+113
View File
@@ -0,0 +1,113 @@
"""Merge records what it absorbed (#2087).
Merging keeps the target's scalar fields, unions locations + tags, and trashes
the sources. Without provenance the fact that a variant ever existed survives
only in the trash — and only for someone who already knew to go looking. So the
survivor carries `merged_from`, written to the body and the indexed mirror from
one value, and ordinary edits must carry it forward rather than erase it.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.services import snippets as s
def _snippet(id=1, owner=9, body=None, data=None, tags=None):
n = MagicMock()
n.id = id
n.user_id = owner
n.title = "formatDuration — humanize a millisecond count"
n.body = body if body is not None else s.compose_body(code="x = 1", language="ts")
n.tags = tags if tags is not None else ["ts", "snippet"]
n.note_type = "snippet"
n.deleted_at = None
# Explicitly None — snippet_fields prefers `data` when truthy, and an
# auto-MagicMock attribute is truthy (note 2109).
n.data = data
return n
async def _run_merge(target, sources, source_ids):
"""Drive merge_snippets with the DB stubbed out; return update_note's kwargs."""
by_id = {target.id: target, **{x.id: x for x in sources}}
async def fake_get(_uid, sid):
return by_id.get(sid)
with patch.object(s, "get_snippet", AsyncMock(side_effect=fake_get)), \
patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)), \
patch.object(s.notes_svc, "update_note",
AsyncMock(return_value=target)) as mock_update, \
patch("scribe.services.trash.delete", AsyncMock(return_value=object())), \
patch.object(s, "_embed_snippet", MagicMock()):
await s.merge_snippets(7, target.id, source_ids)
return mock_update.await_args.kwargs
@pytest.mark.asyncio
async def test_merge_records_what_it_absorbed_in_body_and_mirror():
kwargs = await _run_merge(
_snippet(id=1), [_snippet(id=2), _snippet(id=3)], [2, 3],
)
assert "**Merged from:** #2, #3" in kwargs["body"]
assert kwargs["data"]["merged_from"] == [2, 3]
@pytest.mark.asyncio
async def test_merge_accumulates_across_merges():
"""A target merged twice keeps both histories — the second merge must not
replace the record of the first."""
target = _snippet(id=1, data=s.compose_data(name="f", merged_from=[2]))
kwargs = await _run_merge(target, [_snippet(id=3)], [3])
assert kwargs["data"]["merged_from"] == [2, 3]
assert "**Merged from:** #2, #3" in kwargs["body"]
@pytest.mark.asyncio
async def test_merge_does_not_record_a_skipped_cross_owner_source():
"""A source owned by someone else is skipped, not folded in (#231) — so it
must not appear in the provenance either, or the record would claim to
contain something it never absorbed."""
kwargs = await _run_merge(
_snippet(id=1, owner=9),
[_snippet(id=2, owner=9), _snippet(id=3, owner=42)],
[2, 3],
)
assert kwargs["data"]["merged_from"] == [2]
assert "#3" not in kwargs["body"]
@pytest.mark.asyncio
async def test_an_ordinary_edit_carries_provenance_forward():
"""Only a merge may add to it, but nothing else may drop it — update
recomposes body and mirror from scratch, so an omission here would silently
erase the history on the next unrelated edit."""
note = _snippet(id=1, data=s.compose_data(name="f", language="ts",
merged_from=[2, 3]))
with patch.object(s, "get_snippet", AsyncMock(return_value=note)), \
patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)), \
patch.object(s.notes_svc, "update_note",
AsyncMock(return_value=note)) as mock_update, \
patch.object(s, "_embed_snippet", MagicMock()):
await s.update_snippet(7, 1, signature="f(ms) -> string")
kwargs = mock_update.await_args.kwargs
assert kwargs["data"]["merged_from"] == [2, 3]
assert "**Merged from:** #2, #3" in kwargs["body"]
@pytest.mark.asyncio
async def test_a_pre_0070_row_keeps_provenance_through_the_body():
"""Rows written before the `data` column are never backfilled, so the body
line is their only copy — and it has to survive an edit too."""
body = s.compose_body(code="x = 1", language="ts", merged_from=[5])
note = _snippet(id=1, body=body, data=None)
with patch.object(s, "get_snippet", AsyncMock(return_value=note)), \
patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)), \
patch.object(s.notes_svc, "update_note",
AsyncMock(return_value=note)) as mock_update, \
patch.object(s, "_embed_snippet", MagicMock()):
await s.update_snippet(7, 1, when_to_use="humanize a ms count")
assert "**Merged from:** #5" in mock_update.await_args.kwargs["body"]