From c78b5a37ad36156efe8e1374ffae1f133acaee4d Mon Sep 17 00:00:00 2001 From: Project AEGIS Date: Tue, 28 Apr 2026 22:48:40 -0700 Subject: [PATCH] fix: prevent HistoryItemContent orphaning during duplicate resolution When a duplicate clipboard item was detected, History.add() replaced the incoming item's contents with the existing item's contents without cleanup. The original contents (already persisted to SwiftData) were orphaned as rows with item == nil. Additionally, cascade-deleting the existing item would delete content objects now shared with the replacement. Fix: - Delete incoming item's contents from store before replacement - Detach existing item's contents before cascade deletion - Both scoped to the isModified(item) == nil block (contents transfer path) Test: testDuplicateDoesNotOrphanContents verifies exact 1:1 content-to-item ratio after duplicate merge. Closes #1368 --- Maccy/Observables/History.swift | 4 ++++ MaccyTests/HistoryTests.swift | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/Maccy/Observables/History.swift b/Maccy/Observables/History.swift index e5804ad19..53487b8f7 100644 --- a/Maccy/Observables/History.swift +++ b/Maccy/Observables/History.swift @@ -146,7 +146,11 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length var removedItemIndex: Int? if let existingHistoryItem = findSimilarItem(item) { if isModified(item) == nil { + for content in item.contents { + Storage.shared.context.delete(content) + } item.contents = existingHistoryItem.contents + existingHistoryItem.contents = [] } item.firstCopiedAt = existingHistoryItem.firstCopiedAt item.numberOfCopies += existingHistoryItem.numberOfCopies diff --git a/MaccyTests/HistoryTests.swift b/MaccyTests/HistoryTests.swift index a6d4be983..e072fe32d 100644 --- a/MaccyTests/HistoryTests.swift +++ b/MaccyTests/HistoryTests.swift @@ -1,5 +1,6 @@ import XCTest import Defaults +import SwiftData @testable import Maccy @MainActor @@ -123,6 +124,23 @@ class HistoryTests: XCTestCase { XCTAssertEqual(Set(history.items[0].item.contents), Set(firstContents)) } + func testDuplicateDoesNotOrphanContents() { + let item = historyItem("foo") + history.add(item) + + let descriptor1 = FetchDescriptor() + let contents1 = try? Storage.shared.context.fetch(descriptor1) + XCTAssertEqual(contents1?.count, 1) + + let duplicate = historyItem("foo") + history.add(duplicate) + + let descriptor2 = FetchDescriptor() + let contents2 = try? Storage.shared.context.fetch(descriptor2) + XCTAssertEqual(contents2?.count, 1) + XCTAssertNotNil(contents2?.first?.item) + } + func testAddingItemFromMaccy() { let firstContents = [ HistoryItemContent(