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
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:
@@ -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
|
||||
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:
|
||||
target_id: The snippet to keep (the canonical record).
|
||||
source_ids: Snippet ids to fold into the target and retire. Ids that
|
||||
|
||||
@@ -120,6 +120,23 @@ def _render_location_block(locations: list[dict]) -> str | None:
|
||||
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(
|
||||
*,
|
||||
code: str,
|
||||
@@ -130,6 +147,7 @@ def compose_body(
|
||||
path: str = "",
|
||||
symbol: str = "",
|
||||
locations: list[dict] | None = None,
|
||||
merged_from: list[int] | None = None,
|
||||
) -> str:
|
||||
"""Render structured fields into the snippet body markdown. Empty fields are
|
||||
omitted so the body stays clean.
|
||||
@@ -151,6 +169,14 @@ def compose_body(
|
||||
loc_block = _render_location_block(locs)
|
||||
if 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()
|
||||
code_block = f"```{fence_lang}\n{(code or '').rstrip()}\n```"
|
||||
if header:
|
||||
@@ -166,7 +192,9 @@ _LOC_RE = re.compile(r"^\*\*Location:\*\*\s*(.+?)\s*$", re.MULTILINE)
|
||||
_LOCS_RE = re.compile(
|
||||
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)
|
||||
_ID_RE = re.compile(r"#(\d+)")
|
||||
|
||||
|
||||
def _parse_location_str(s: str) -> dict | None:
|
||||
@@ -202,6 +230,7 @@ def parse_snippet_fields(
|
||||
"path": "",
|
||||
"symbol": "",
|
||||
"locations": [],
|
||||
"merged_from": [],
|
||||
"code": "",
|
||||
}
|
||||
|
||||
@@ -235,6 +264,12 @@ def parse_snippet_fields(
|
||||
fields["path"] = locations[0]["path"]
|
||||
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)
|
||||
if m:
|
||||
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,
|
||||
# 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(
|
||||
@@ -264,6 +301,7 @@ def compose_data(
|
||||
signature: str = "",
|
||||
language: str = "",
|
||||
locations: list[dict] | None = None,
|
||||
merged_from: list[int] | None = None,
|
||||
) -> dict:
|
||||
"""Build the `notes.data` mirror of a snippet's structured fields.
|
||||
|
||||
@@ -284,6 +322,9 @@ def compose_data(
|
||||
locs = _normalize_locations(locations)
|
||||
if locs:
|
||||
out["locations"] = locs
|
||||
merged = _normalize_merged_from(merged_from)
|
||||
if merged:
|
||||
out["merged_from"] = merged
|
||||
return out
|
||||
|
||||
|
||||
@@ -478,6 +519,9 @@ async def update_snippet(
|
||||
code=merged["code"], language=merged["language"],
|
||||
signature=merged["signature"], when_to_use=merged["when_to_use"],
|
||||
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
|
||||
# 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"],
|
||||
signature=merged["signature"], language=merged["language"],
|
||||
locations=merged_locations,
|
||||
merged_from=merged.get("merged_from"),
|
||||
),
|
||||
}
|
||||
# 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;
|
||||
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
|
||||
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
|
||||
target = await get_snippet(user_id, target_id)
|
||||
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]
|
||||
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.
|
||||
updated = await notes_svc.update_note(
|
||||
owner_id, target_id,
|
||||
body=compose_body(
|
||||
code=tgt_fields["code"], language=tgt_fields["language"],
|
||||
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),
|
||||
# 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(
|
||||
name=tgt_fields["name"], when_to_use=tgt_fields["when_to_use"],
|
||||
signature=tgt_fields["signature"], language=tgt_fields["language"],
|
||||
locations=locations,
|
||||
locations=locations, merged_from=merged_from,
|
||||
),
|
||||
)
|
||||
if updated is None:
|
||||
|
||||
Reference in New Issue
Block a user