v0.14.0: a removal you make sticks

Round 43 made the merge report honest, and in doing so made its one real
limitation impossible to miss: every merge was a union, so a song removed from a
playlist on one machine was handed straight back by the other on the next sync,
and a track deleted from the library came back with it. A deletion you cannot
make stick is not a deletion.

The union was there for a real reason — two copies with no common ancestor
cannot tell "A added this" from "B removed it" — so the missing evidence is
written down instead of inferred. New lintunes/tombstones.py: a playlist keeps
track_events {track id: [when, add|remove]}, the library keeps deleted_tracks
{track id: when} in library_metadata.json, and a merge applies the newest event
per track across both copies. A removal beats a copy that merely still had the
song; a deliberate re-add afterwards beats the removal; a track nobody touched
still merges as a union, which stays the safe behavior where there is no
evidence either way.

Deliberately not "the newer copy wins wholesale": that one-liner silently drops
a song the other machine added while you were removing one, which
test_an_unrelated_addition_is_not_lost pins.

Events are recorded in the funnels that already exist — _set_track_ids diffs
before/after so a reorder records nothing, _remove_tracks stamps the library,
_restore_tracks clears it so Ctrl+Z takes the tombstone back — and pruned after
30 days at the save boundary.

Three edges worth naming:

* Track ids are never reused. The next id came from max(library.tracks), so
  deleting the highest-numbered track freed its id, and the next import would be
  dropped on sight by the dead id's own tombstone on every machine.
* library_metadata.json is merged before library.json, since it carries the
  record the library merge is filtered against and rglob order is not a plan.
* A merge applying a deletion never touches a music file — it drops the library
  entry only, and test_a_merge_never_touches_a_music_file fails the run if
  send_to_trash is so much as called. Applied removals grade WARNING and name
  the song.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y9ZEFi4qNJ39FMiBtiAxy2
This commit is contained in:
2026-08-27 18:31:33 -04:00
co-authored by Claude Opus 5
parent c8de124543
commit 8c097faacc
12 changed files with 594 additions and 26 deletions
+50
View File
@@ -1,5 +1,55 @@
## Done
### Round 44 (2026-08-27) — A removal you make sticks (v0.14.0)
Round 43 made the merge report honest, which made its one real limitation
impossible to miss: every merge was a union, so a song removed from a playlist
on one machine was handed straight back by the other on the next sync, and a
track deleted from the library came back with it. A deletion you cannot make
stick is not a deletion.
- [x] **Removals are recorded, not inferred.** The union was there for a real
reason — two copies and no common ancestor cannot tell "A added this" from
"B removed it" — so the missing evidence is now written down. New
`lintunes/tombstones.py`: `Playlist.track_events` holds
`{track id: [when, "add"|"remove"]}` and `Library.deleted_tracks` holds
`{track id: when}` in `library_metadata.json`. A merge takes the newest
event per track across both copies and applies it. A removal beats a copy
that merely still had the song; a deliberate re-add afterwards beats the
removal; a track nobody touched still merges as a union, which is the safe
old behavior for everything that has no evidence either way.
- [x] **Not "the newer copy wins wholesale".** That was the tempting one-line
version and it silently drops a song the other machine added while you
were removing one. `test_an_unrelated_addition_is_not_lost` pins it.
- [x] **Recorded in the existing funnels.** `_set_track_ids` diffs before/after
(so a reorder records nothing), `_remove_tracks` stamps
`deleted_tracks`, and `_restore_tracks` clears it, so Ctrl+Z on a delete
takes the tombstone back with it.
- [x] **Track ids are never reused.** The sharpest edge in the whole design: the
next id came from `max(library.tracks)`, so deleting the highest-numbered
track freed its id for the next import — and that new track would be
dropped on sight by the dead id's own tombstone, on every machine.
`_highest_track_id` counts the deletions too.
- [x] **`library_metadata.json` merges before `library.json`.** It carries the
record the library merge filters against, and `rglob` order is not a plan.
`_merge_metadata` unions the two copies' `deleted_tracks` regardless of
which whole copy the mtime pick kept.
- [x] **A merge applying a deletion never touches a music file.** It drops the
library entry only — the file was already trashed on the machine where the
delete happened, and the music folder is its own Syncthing share.
`test_a_merge_never_touches_a_music_file` fails the run if `send_to_trash`
is so much as called.
- [x] **Reported as a warning, with the song named.** An applied removal grades
WARNING so it opens the merge window, names each track, and says Ctrl+Z
does not reach a merge. `_Labeler.remember` keeps the name of a track
library.json is about to lose, so the playlist merge can still say which
song it dropped rather than "track 4".
- [x] **Events expire after 30 days** (`RETENTION_DAYS`), pruned at the save
boundary like the derived-membership gate. The trade is stated in the
module docstring: a machine offline longer than that can resurrect a song
it never learned was deleted.
### Round 43 (2026-08-27) — The merge report says who, what, and where (v0.13.0)
The merge window kept saying `Order kept from this machine (most recently