@@ -180,3 +180,213 @@ async def test_parser_failure_preserves_existing_index_entries() -> None:
180180 store .delete_by_file .assert_not_called ()
181181 store .upsert_chunks .assert_not_called ()
182182 assert result ["files" ] == 0
183+
184+
185+ async def test_unchanged_file_is_skipped_without_fetching_content () -> None :
186+ svc = ServiceConfig (name = "svc" , github_repo = "org/repo" , exclude = [])
187+ github_file = GitHubFile (rel_path = "unchanged.py" , blob_sha = "same-sha" )
188+
189+ store = AsyncMock ()
190+ store .get_indexed_file_hashes .return_value = {"svc/unchanged.py" : "same-sha" }
191+
192+ pipeline = _make_pipeline (store )
193+
194+ with (
195+ patch .object (
196+ type (pipeline_module .settings ), "load_services" , return_value = [svc ]
197+ ),
198+ patch .object (
199+ pipeline_module , "list_github_files" , AsyncMock (return_value = [github_file ])
200+ ),
201+ patch .object (pipeline_module , "fetch_blob_content" , AsyncMock ()) as mock_fetch ,
202+ ):
203+ result = await pipeline .index_service ("svc" , force = False )
204+
205+ mock_fetch .assert_not_called ()
206+ store .delete_by_file .assert_not_called ()
207+ assert result == {"files" : 0 , "chunks" : 0 , "skipped" : 1 }
208+
209+
210+ async def test_force_reindexes_even_when_hash_unchanged () -> None :
211+ svc = ServiceConfig (name = "svc" , github_repo = "org/repo" , exclude = [])
212+ github_file = GitHubFile (rel_path = "unchanged.py" , blob_sha = "same-sha" )
213+ symbol = CodeSymbol (
214+ name = "fn" ,
215+ symbol_type = "function" ,
216+ language = "python" ,
217+ source = "def fn(): pass" ,
218+ file_path = "svc/unchanged.py" ,
219+ start_line = 1 ,
220+ end_line = 1 ,
221+ )
222+
223+ store = AsyncMock ()
224+ store .get_indexed_file_hashes .return_value = {"svc/unchanged.py" : "same-sha" }
225+
226+ pipeline = _make_pipeline (store )
227+
228+ with (
229+ patch .object (
230+ type (pipeline_module .settings ), "load_services" , return_value = [svc ]
231+ ),
232+ patch .object (
233+ pipeline_module , "list_github_files" , AsyncMock (return_value = [github_file ])
234+ ),
235+ patch .object (
236+ pipeline_module ,
237+ "fetch_blob_content" ,
238+ AsyncMock (return_value = b"def fn(): pass" ),
239+ ),
240+ patch .object (pipeline_module , "parse_file" , return_value = [symbol ]),
241+ ):
242+ result = await pipeline .index_service ("svc" , force = True )
243+
244+ store .delete_by_file .assert_called_once_with ("svc" , "svc/unchanged.py" )
245+ store .upsert_chunks .assert_called_once ()
246+ assert result == {"files" : 1 , "chunks" : 1 , "skipped" : 0 }
247+
248+
249+ async def test_stale_index_entries_removed_when_file_no_longer_in_repo () -> None :
250+ svc = ServiceConfig (name = "svc" , github_repo = "org/repo" , exclude = [])
251+
252+ store = AsyncMock ()
253+ store .get_indexed_file_hashes .return_value = {
254+ "svc/deleted.py" : "old-sha" ,
255+ "svc/still-there.py" : "sha" ,
256+ }
257+
258+ pipeline = _make_pipeline (store )
259+
260+ with (
261+ patch .object (
262+ type (pipeline_module .settings ), "load_services" , return_value = [svc ]
263+ ),
264+ patch .object (
265+ pipeline_module ,
266+ "list_github_files" ,
267+ AsyncMock (
268+ return_value = [GitHubFile (rel_path = "still-there.py" , blob_sha = "sha" )]
269+ ),
270+ ),
271+ ):
272+ result = await pipeline .index_service ("svc" , force = False )
273+
274+ store .delete_by_file .assert_called_once_with ("svc" , "svc/deleted.py" )
275+ assert result ["skipped" ] == 1
276+
277+
278+ async def test_file_with_no_symbols_deletes_stale_entries_and_skips_upsert () -> None :
279+ svc = ServiceConfig (name = "svc" , github_repo = "org/repo" , exclude = [])
280+ github_file = GitHubFile (rel_path = "empty.py" , blob_sha = "sha1" )
281+
282+ store = AsyncMock ()
283+ store .get_indexed_file_hashes .return_value = {}
284+
285+ pipeline = _make_pipeline (store )
286+
287+ with (
288+ patch .object (
289+ type (pipeline_module .settings ), "load_services" , return_value = [svc ]
290+ ),
291+ patch .object (
292+ pipeline_module , "list_github_files" , AsyncMock (return_value = [github_file ])
293+ ),
294+ patch .object (
295+ pipeline_module , "fetch_blob_content" , AsyncMock (return_value = b"" )
296+ ),
297+ patch .object (pipeline_module , "parse_file" , return_value = []),
298+ ):
299+ result = await pipeline .index_service ("svc" , force = True )
300+
301+ store .delete_by_file .assert_called_once_with ("svc" , "svc/empty.py" )
302+ store .upsert_chunks .assert_not_called ()
303+ assert result ["files" ] == 0
304+
305+
306+ async def test_embedding_failure_preserves_existing_index_entries () -> None :
307+ svc = ServiceConfig (name = "svc" , github_repo = "org/repo" , exclude = [])
308+ github_file = GitHubFile (rel_path = "broken.py" , blob_sha = "sha1" )
309+ symbol = CodeSymbol (
310+ name = "fn" ,
311+ symbol_type = "function" ,
312+ language = "python" ,
313+ source = "def fn(): pass" ,
314+ file_path = "svc/broken.py" ,
315+ start_line = 1 ,
316+ end_line = 1 ,
317+ )
318+
319+ store = AsyncMock ()
320+ store .get_indexed_file_hashes .return_value = {"svc/broken.py" : "old-sha" }
321+
322+ pipeline = _make_pipeline (store )
323+ pipeline ._embedder .embed_batch = AsyncMock (side_effect = RuntimeError ("503" ))
324+
325+ with (
326+ patch .object (
327+ type (pipeline_module .settings ), "load_services" , return_value = [svc ]
328+ ),
329+ patch .object (
330+ pipeline_module , "list_github_files" , AsyncMock (return_value = [github_file ])
331+ ),
332+ patch .object (
333+ pipeline_module ,
334+ "fetch_blob_content" ,
335+ AsyncMock (return_value = b"def fn(): pass" ),
336+ ),
337+ patch .object (pipeline_module , "parse_file" , return_value = [symbol ]),
338+ ):
339+ result = await pipeline .index_service ("svc" , force = True )
340+
341+ store .delete_by_file .assert_not_called ()
342+ store .upsert_chunks .assert_not_called ()
343+ assert result ["files" ] == 0
344+
345+
346+ async def test_delete_by_file_called_before_upsert_for_changed_file () -> None :
347+ svc = ServiceConfig (name = "svc" , github_repo = "org/repo" , exclude = [])
348+ github_file = GitHubFile (rel_path = "mod.py" , blob_sha = "new-sha" )
349+ symbol = CodeSymbol (
350+ name = "fn" ,
351+ symbol_type = "function" ,
352+ language = "python" ,
353+ source = "def fn(): pass" ,
354+ file_path = "svc/mod.py" ,
355+ start_line = 1 ,
356+ end_line = 1 ,
357+ )
358+
359+ call_order : list [str ] = []
360+ store = AsyncMock ()
361+ store .get_indexed_file_hashes .return_value = {"svc/mod.py" : "old-sha" }
362+ store .delete_by_file .side_effect = lambda * a , ** k : call_order .append ("delete" )
363+ store .upsert_chunks .side_effect = lambda * a , ** k : call_order .append ("upsert" )
364+
365+ pipeline = _make_pipeline (store )
366+
367+ with (
368+ patch .object (
369+ type (pipeline_module .settings ), "load_services" , return_value = [svc ]
370+ ),
371+ patch .object (
372+ pipeline_module , "list_github_files" , AsyncMock (return_value = [github_file ])
373+ ),
374+ patch .object (
375+ pipeline_module ,
376+ "fetch_blob_content" ,
377+ AsyncMock (return_value = b"def fn(): pass" ),
378+ ),
379+ patch .object (pipeline_module , "parse_file" , return_value = [symbol ]),
380+ ):
381+ await pipeline .index_service ("svc" , force = False )
382+
383+ assert call_order == ["delete" , "upsert" ]
384+
385+
386+ async def test_unknown_service_returns_error_without_touching_github () -> None :
387+ pipeline = _make_pipeline (AsyncMock ())
388+
389+ with patch .object (type (pipeline_module .settings ), "load_services" , return_value = []):
390+ result = await pipeline .index_service ("missing" )
391+
392+ assert result == {"error" : 1 , "files" : 0 , "chunks" : 0 }
0 commit comments