diff --git a/code_review_graph/graph.py b/code_review_graph/graph.py index 2dfa97fc..ea2fa6fe 100644 --- a/code_review_graph/graph.py +++ b/code_review_graph/graph.py @@ -236,6 +236,11 @@ def store_file_nodes_edges( self, file_path: str, nodes: list[NodeInfo], edges: list[EdgeInfo], fhash: str = "" ) -> None: """Atomically replace all data for a file.""" + # Flush any pending implicit transaction opened by earlier DML on this + # connection so the explicit BEGIN IMMEDIATE below doesn't trip SQLite's + # "cannot start a transaction within a transaction" error. + if self._conn.in_transaction: + self._conn.commit() self._conn.execute("BEGIN IMMEDIATE") try: self.remove_file_data(file_path) diff --git a/code_review_graph/incremental.py b/code_review_graph/incremental.py index 863211bb..f67e894c 100644 --- a/code_review_graph/incremental.py +++ b/code_review_graph/incremental.py @@ -352,8 +352,13 @@ def full_build(repo_root: Path, store: GraphStore) -> dict: # Purge stale data from files no longer on disk existing_files = set(store.get_all_files()) current_abs = {str(repo_root / f) for f in files} - for stale in existing_files - current_abs: - store.remove_file_data(stale) + stale_files = existing_files - current_abs + if stale_files: + for stale in stale_files: + store.remove_file_data(stale) + # Commit the implicit transaction opened by the DELETEs so the + # subsequent BEGIN IMMEDIATE in store_file_nodes_edges succeeds. + store.commit() total_nodes = 0 total_edges = 0 diff --git a/tests/test_graph.py b/tests/test_graph.py index 5923f578..5ea757cb 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -107,6 +107,25 @@ def test_store_file_nodes_edges(self): result = self.store.get_nodes_by_file("/test/file.py") assert len(result) == 2 + def test_store_file_nodes_edges_after_dangling_dml(self): + """Regression: store_file_nodes_edges must recover when the connection + is already inside an implicit transaction opened by a prior DML (e.g. + the stale-file purge loop in full_build). Previously this raised + 'cannot start a transaction within a transaction'. + """ + # Simulate the purge loop: run DELETEs without committing so the + # connection is left in an implicit transaction. + self.store.upsert_node(self._make_file_node("/stale.py")) + self.store.commit() + self.store.remove_file_data("/stale.py") + assert self.store._conn.in_transaction + + nodes = [self._make_file_node(), self._make_func_node()] + # Should not raise sqlite3.OperationalError. + self.store.store_file_nodes_edges("/test/file.py", nodes, []) + + assert len(self.store.get_nodes_by_file("/test/file.py")) == 2 + def test_search_nodes(self): self.store.upsert_node(self._make_func_node("authenticate")) self.store.upsert_node(self._make_func_node("authorize"))