Skip to content

Commit 2438094

Browse files
phernandezclaude[bot]jope-bmclaude
authored
fix: handle vim atomic write DELETE events without ADD (#249)
Signed-off-by: Joe P <joe@basicmemory.com> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com> Co-authored-by: Joe P <joe@basicmemory.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5d74d74 commit 2438094

2 files changed

Lines changed: 235 additions & 7 deletions

File tree

src/basic_memory/sync/watch_service.py

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -284,13 +284,36 @@ async def handle_changes(self, project: Project, changes: Set[FileChange]) -> No
284284
# Process deletes
285285
for path in deletes:
286286
if path not in processed:
287-
logger.debug("Processing deleted file", path=path)
288-
await sync_service.handle_delete(path)
289-
self.state.add_event(path=path, action="deleted", status="success")
290-
self.console.print(f"[red]✕[/red] {path}")
291-
logger.info(f"deleted: {path}")
292-
processed.add(path)
293-
delete_count += 1
287+
# Check if file still exists on disk (vim atomic write edge case)
288+
full_path = directory / path
289+
if full_path.exists() and full_path.is_file():
290+
# File still exists despite DELETE event - treat as modification
291+
logger.debug("File exists despite DELETE event, treating as modification", path=path)
292+
entity, checksum = await sync_service.sync_file(path, new=False)
293+
self.state.add_event(path=path, action="modified", status="success", checksum=checksum)
294+
self.console.print(f"[yellow]✎[/yellow] {path} (atomic write)")
295+
logger.info(f"atomic write detected: {path}")
296+
processed.add(path)
297+
modify_count += 1
298+
else:
299+
# Check if this was a directory - skip if so
300+
# (we can't tell if the deleted path was a directory since it no longer exists,
301+
# so we check if there's an entity in the database for it)
302+
entity = await sync_service.entity_repository.get_by_file_path(path)
303+
if entity is None:
304+
# No entity means this was likely a directory - skip it
305+
logger.debug(f"Skipping deleted path with no entity (likely directory), path={path}")
306+
processed.add(path)
307+
continue
308+
309+
# File truly deleted
310+
logger.debug("Processing deleted file", path=path)
311+
await sync_service.handle_delete(path)
312+
self.state.add_event(path=path, action="deleted", status="success")
313+
self.console.print(f"[red]✕[/red] {path}")
314+
logger.info(f"deleted: {path}")
315+
processed.add(path)
316+
delete_count += 1
294317

295318
# Process adds
296319
for path in adds:

tests/sync/test_watch_service_edge_cases.py

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,208 @@ async def test_handle_changes_empty_set(watch_service, project_config, test_proj
6666

6767
# Verify synced_files wasn't changed
6868
assert watch_service.state.synced_files == 0
69+
70+
71+
@pytest.mark.asyncio
72+
async def test_handle_vim_atomic_write_delete_still_exists(watch_service, project_config, test_project, sync_service):
73+
"""Test vim atomic write scenario: DELETE event but file still exists on disk."""
74+
project_dir = project_config.home
75+
76+
# Create initial file and sync it
77+
test_file = project_dir / "vim_test.md"
78+
initial_content = """---
79+
type: note
80+
title: vim test
81+
---
82+
# Vim Test
83+
Initial content for atomic write test
84+
"""
85+
test_file.write_text(initial_content)
86+
await sync_service.sync(project_dir)
87+
88+
# Get initial entity state
89+
initial_entity = await sync_service.entity_repository.get_by_file_path("vim_test.md")
90+
assert initial_entity is not None
91+
initial_checksum = initial_entity.checksum
92+
93+
# Simulate vim's atomic write: modify content but send DELETE event
94+
# (vim moves original file, creates new content, then deletes old inode)
95+
modified_content = """---
96+
type: note
97+
title: vim test
98+
---
99+
# Vim Test
100+
Modified content after atomic write
101+
"""
102+
test_file.write_text(modified_content)
103+
104+
# Setup DELETE event even though file still exists (vim's atomic write behavior)
105+
# Use absolute path like the real watch service would
106+
changes = {(Change.deleted, str(test_file))}
107+
108+
# Handle the change
109+
await watch_service.handle_changes(test_project, changes)
110+
111+
# Verify the entity still exists and was updated (not deleted)
112+
entity = await sync_service.entity_repository.get_by_file_path("vim_test.md")
113+
assert entity is not None
114+
assert entity.id == initial_entity.id # Same entity
115+
assert entity.checksum != initial_checksum # Checksum should be updated
116+
117+
# Verify the file content was properly synced
118+
actual_content = test_file.read_text()
119+
assert "Modified content after atomic write" in actual_content
120+
121+
# Check that correct event was recorded (should be "modified", not "deleted")
122+
events = [e for e in watch_service.state.recent_events if e.path == "vim_test.md"]
123+
assert len(events) == 1
124+
assert events[0].action == "modified"
125+
assert events[0].status == "success"
126+
127+
128+
@pytest.mark.asyncio
129+
async def test_handle_true_deletion_vs_vim_atomic(watch_service, project_config, test_project, sync_service):
130+
"""Test that true deletions are still handled correctly vs vim atomic writes."""
131+
project_dir = project_config.home
132+
133+
# Create and sync two files
134+
atomic_file = project_dir / "atomic_test.md"
135+
delete_file = project_dir / "delete_test.md"
136+
137+
content = """---
138+
type: note
139+
---
140+
# Test File
141+
Content for testing
142+
"""
143+
144+
atomic_file.write_text(content)
145+
delete_file.write_text(content)
146+
await sync_service.sync(project_dir)
147+
148+
# For atomic_file: modify content but keep file (vim atomic write scenario)
149+
modified_content = content.replace("Content for testing", "Modified content")
150+
atomic_file.write_text(modified_content)
151+
152+
# For delete_file: actually delete it (true deletion)
153+
delete_file.unlink()
154+
155+
# Setup DELETE events for both files
156+
# Use absolute paths like the real watch service would
157+
changes = {
158+
(Change.deleted, str(atomic_file)), # File still exists - atomic write
159+
(Change.deleted, str(delete_file)), # File deleted - true deletion
160+
}
161+
162+
# Handle the changes
163+
await watch_service.handle_changes(test_project, changes)
164+
165+
# Verify atomic_file was treated as modification (still exists in DB)
166+
atomic_entity = await sync_service.entity_repository.get_by_file_path("atomic_test.md")
167+
assert atomic_entity is not None
168+
169+
# Verify delete_file was truly deleted (no longer exists in DB)
170+
delete_entity = await sync_service.entity_repository.get_by_file_path("delete_test.md")
171+
assert delete_entity is None
172+
173+
# Check events were recorded correctly
174+
events = watch_service.state.recent_events
175+
atomic_events = [e for e in events if e.path == "atomic_test.md"]
176+
delete_events = [e for e in events if e.path == "delete_test.md"]
177+
178+
assert len(atomic_events) == 1
179+
assert atomic_events[0].action == "modified"
180+
181+
assert len(delete_events) == 1
182+
assert delete_events[0].action == "deleted"
183+
184+
185+
@pytest.mark.asyncio
186+
async def test_handle_vim_atomic_write_markdown_with_relations(watch_service, project_config, test_project, sync_service):
187+
"""Test vim atomic write with markdown files that contain relations."""
188+
project_dir = project_config.home
189+
190+
# Create target file for relations
191+
target_file = project_dir / "target.md"
192+
target_content = """---
193+
type: note
194+
title: Target Note
195+
---
196+
# Target Note
197+
This is the target of relations.
198+
"""
199+
target_file.write_text(target_content)
200+
201+
# Create main file with relations
202+
main_file = project_dir / "main.md"
203+
initial_content = """---
204+
type: note
205+
title: Main Note
206+
---
207+
# Main Note
208+
This note links to [[Target Note]].
209+
210+
- relates_to [[Target Note]]
211+
"""
212+
main_file.write_text(initial_content)
213+
await sync_service.sync(project_dir)
214+
215+
# Get initial state
216+
main_entity = await sync_service.entity_repository.get_by_file_path("main.md")
217+
assert main_entity is not None
218+
initial_relations = len(main_entity.relations)
219+
220+
# Simulate vim atomic write with content change that adds more relations
221+
modified_content = """---
222+
type: note
223+
title: Main Note
224+
---
225+
# Main Note
226+
This note links to [[Target Note]] multiple times.
227+
228+
- relates_to [[Target Note]]
229+
- references [[Target Note]]
230+
"""
231+
main_file.write_text(modified_content)
232+
233+
# Setup DELETE event (vim atomic write)
234+
# Use absolute path like the real watch service would
235+
changes = {(Change.deleted, str(main_file))}
236+
237+
# Handle the change
238+
await watch_service.handle_changes(test_project, changes)
239+
240+
# Verify entity still exists and relations were updated
241+
updated_entity = await sync_service.entity_repository.get_by_file_path("main.md")
242+
assert updated_entity is not None
243+
assert updated_entity.id == main_entity.id
244+
245+
# Verify relations were processed correctly
246+
updated_relations = len(updated_entity.relations)
247+
assert updated_relations >= initial_relations # Should have at least as many relations
248+
249+
# Check event was recorded as modification
250+
events = [e for e in watch_service.state.recent_events if e.path == "main.md"]
251+
assert len(events) == 1
252+
assert events[0].action == "modified"
253+
254+
255+
@pytest.mark.asyncio
256+
async def test_handle_vim_atomic_write_directory_path_ignored(watch_service, project_config, test_project):
257+
"""Test that directories are properly ignored even in atomic write detection."""
258+
project_dir = project_config.home
259+
260+
# Create directory
261+
test_dir = project_dir / "test_directory"
262+
test_dir.mkdir()
263+
264+
# Setup DELETE event for directory (should be ignored)
265+
# Use absolute path like the real watch service would
266+
changes = {(Change.deleted, str(test_dir))}
267+
268+
# Handle the change - should not cause errors
269+
await watch_service.handle_changes(test_project, changes)
270+
271+
# Verify no events were recorded for the directory
272+
events = [e for e in watch_service.state.recent_events if "test_directory" in e.path]
273+
assert len(events) == 0

0 commit comments

Comments
 (0)