From 7d46e24036213ed2691cfc978d771ed26b165d36 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Wed, 11 Nov 2026 16:20:27 -0600 Subject: [PATCH 1/2] fix(git): remove read-only .git packfiles on Windows when falling back to fresh clone --- REQUIREMENTS.md | 8 +++-- app/services/git_manager.py | 46 +++++++++++++++++++----- tests/backend/test_git_manager.py | 58 +++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 11 deletions(-) diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index aa4c509..dfb5f2a 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -2,7 +2,7 @@ > **Note:** This document is automatically generated and verified against the live test suite by `scripts/generate_requirements.py` and `tests/backend/test_requirements_sync.py`. -**Test Verification Baseline:** **1180 Automated Tests** (820 Pytest Backend + 310 Vitest Frontend + 50 Playwright E2E). +**Test Verification Baseline:** **1184 Automated Tests** (824 Pytest Backend + 310 Vitest Frontend + 50 Playwright E2E). --- @@ -536,7 +536,7 @@ classDiagram - `test_get_recent_changes_mcp_tool` - `test_rest_api_patch_retain_commits` -#### `tests/backend/test_git_manager.py` (34 tests) +#### `tests/backend/test_git_manager.py` (38 tests) - `TestGitManager::test_get_env_token` - `TestGitManager::test_normalize_git_url` - `TestGitManager::test_build_authenticated_url` @@ -551,6 +551,8 @@ classDiagram - `TestGitManager::test_shallow_clone_repo_failure` - `TestGitManager::test_shallow_clone_repo_exception` - `TestGitManager::test_cleanup_repo_dir_exception` +- `TestGitManager::test_cleanup_repo_dir_removes_readonly_files` +- `TestGitManager::test_shallow_clone_incremental_fetch_failure_recovers_with_fresh_clone` - `TestGitManager::test_check_github_rate_limit_success` - `TestGitManager::test_check_github_rate_limit_non_200` - `TestGitManager::test_check_github_rate_limit_exception` @@ -568,6 +570,8 @@ classDiagram - `test_shallow_clone_repo_failure` - `test_shallow_clone_repo_exception` - `test_cleanup_repo_dir_exception` +- `test_cleanup_repo_dir_removes_readonly_files` +- `test_shallow_clone_incremental_fetch_failure_recovers_with_fresh_clone` - `test_check_github_rate_limit_success` - `test_check_github_rate_limit_non_200` - `test_check_github_rate_limit_exception` diff --git a/app/services/git_manager.py b/app/services/git_manager.py index c86c1e0..c86bcbe 100644 --- a/app/services/git_manager.py +++ b/app/services/git_manager.py @@ -235,13 +235,19 @@ def shallow_clone_repo( # Check if already a git repo in target_dir (incremental shallow update) if target_dir and os.path.exists(os.path.join(repo_dir, ".git")): logger.info(f"Existing shallow clone found in {repo_dir}; fetching latest refs (depth: {depth_str})...") + subprocess.run(["git", "remote", "set-url", "origin", auth_url], cwd=repo_dir, capture_output=True, text=True, timeout=15, env=GIT_ENV) fetch_cmd = ["git", "fetch", "--depth", depth_str, "origin", branch] f_res = subprocess.run(fetch_cmd, cwd=repo_dir, capture_output=True, text=True, timeout=60, env=GIT_ENV) if f_res.returncode == 0: - subprocess.run(["git", "reset", "--hard", f"origin/{branch}"], cwd=repo_dir, capture_output=True, text=True, timeout=30, env=GIT_ENV) - sha_res = subprocess.run(["git", "rev-parse", "HEAD"], cwd=repo_dir, capture_output=True, text=True, timeout=10, env=GIT_ENV) - commit_sha = sha_res.stdout.strip() if sha_res.returncode == 0 else "unknown" - return CloneResult(temp_dir=repo_dir, commit_sha=commit_sha, error=None) + r_res = subprocess.run(["git", "reset", "--hard", f"origin/{branch}"], cwd=repo_dir, capture_output=True, text=True, timeout=30, env=GIT_ENV) + if r_res.returncode != 0: + r_res = subprocess.run(["git", "reset", "--hard", "FETCH_HEAD"], cwd=repo_dir, capture_output=True, text=True, timeout=30, env=GIT_ENV) + if r_res.returncode == 0: + sha_res = subprocess.run(["git", "rev-parse", "HEAD"], cwd=repo_dir, capture_output=True, text=True, timeout=10, env=GIT_ENV) + commit_sha = sha_res.stdout.strip() if sha_res.returncode == 0 else "unknown" + return CloneResult(temp_dir=repo_dir, commit_sha=commit_sha, error=None) + logger.warning(f"Git reset failed in {repo_dir}; falling back to fresh clone: {r_res.stderr.strip()}") + cleanup_repo_dir(repo_dir) else: logger.warning(f"Incremental fetch failed in {repo_dir}; falling back to fresh clone: {f_res.stderr.strip()}") cleanup_repo_dir(repo_dir) @@ -249,6 +255,9 @@ def shallow_clone_repo( logger.warning(f"Target directory {repo_dir} exists but is not a valid git repository; cleaning up for fresh clone...") cleanup_repo_dir(repo_dir) + if os.path.exists(repo_dir) and os.listdir(repo_dir): + cleanup_repo_dir(repo_dir) + # Shallow clone single branch cmd = [ "git", "clone", @@ -260,13 +269,14 @@ def shallow_clone_repo( ] res = subprocess.run(cmd, capture_output=True, text=True, timeout=120, env=GIT_ENV) if res.returncode != 0: + if os.path.exists(repo_dir): + cleanup_repo_dir(repo_dir) # Fallback to default clone if branch failed (e.g. branch is default) cmd_fallback = ["git", "clone", "--depth", depth_str, auth_url, repo_dir] res = subprocess.run(cmd_fallback, capture_output=True, text=True, timeout=120, env=GIT_ENV) if res.returncode != 0: err_msg = res.stderr.strip() or res.stdout.strip() or "Git clone failed" - if not target_dir: - cleanup_repo_dir(repo_dir) + cleanup_repo_dir(repo_dir) return CloneResult(temp_dir=None, commit_sha=None, error=f"Clone failed: {err_msg}") # Get Commit SHA @@ -275,7 +285,7 @@ def shallow_clone_repo( return CloneResult(temp_dir=repo_dir, commit_sha=commit_sha, error=None) except Exception as e: - if not target_dir: + if not target_dir or not os.path.exists(os.path.join(repo_dir, ".git")): cleanup_repo_dir(repo_dir) return CloneResult(temp_dir=None, commit_sha=None, error=f"Exception during git clone: {str(e)}") @@ -350,11 +360,29 @@ def extract_recent_commits(repo_dir: str, limit: int = 20) -> list[Dict[str, Any logger.error(f"Error extracting recent commits from {repo_dir}: {e}") return [] +def _handle_remove_readonly(func, path, exc_info): + """Clears the read-only bit (e.g. on .git/objects/pack/* on Windows) and retries deletion.""" + import stat + try: + os.chmod(path, stat.S_IWRITE | stat.S_IREAD) + func(path) + except Exception as e: + logger.debug(f"Could not force-remove {path}: {e}") + def cleanup_repo_dir(repo_dir: Optional[str]): - """Safely removes cloned temporary directory from disk.""" + """Safely removes cloned directory from disk, handling read-only .git packfiles on Windows.""" if repo_dir and os.path.exists(repo_dir): try: - shutil.rmtree(repo_dir, ignore_errors=True) + shutil.rmtree(repo_dir, onerror=_handle_remove_readonly) + if os.path.exists(repo_dir): + # If a file handle is still briefly held on Windows, move the directory aside so a fresh clone can proceed + import time + stale_path = f"{repo_dir}.stale.{int(time.time() * 1000)}" + try: + os.replace(repo_dir, stale_path) + shutil.rmtree(stale_path, onerror=_handle_remove_readonly) + except Exception as move_err: + logger.warning(f"Failed to move locked repo directory {repo_dir}: {move_err}") logger.info(f"Cleaned up temporary repo directory: {repo_dir}") except Exception as e: logger.warning(f"Failed to cleanup temp directory {repo_dir}: {e}") diff --git a/tests/backend/test_git_manager.py b/tests/backend/test_git_manager.py index 7b694df..a235917 100644 --- a/tests/backend/test_git_manager.py +++ b/tests/backend/test_git_manager.py @@ -212,6 +212,64 @@ def test_cleanup_repo_dir_exception(self, mock_rmtree): if os.path.exists(test_dir): os.rmdir(test_dir) + def test_cleanup_repo_dir_removes_readonly_files(self): + import stat + import tempfile + test_dir = tempfile.mkdtemp(prefix="test_readonly_git_") + pack_dir = os.path.join(test_dir, ".git", "objects", "pack") + os.makedirs(pack_dir, exist_ok=True) + pack_file = os.path.join(pack_dir, "pack-12345.pack") + with open(pack_file, "wb") as f: + f.write(b"packdata") + os.chmod(pack_file, stat.S_IREAD) + cleanup_repo_dir(test_dir) + self.assertFalse(os.path.exists(test_dir)) + + @patch("subprocess.run") + def test_shallow_clone_incremental_fetch_failure_recovers_with_fresh_clone(self, mock_run): + import stat + import tempfile + target_dir = tempfile.mkdtemp(prefix="test_persistent_repo_") + pack_dir = os.path.join(target_dir, ".git", "objects", "pack") + os.makedirs(pack_dir, exist_ok=True) + pack_file = os.path.join(pack_dir, "pack-readonly.idx") + with open(pack_file, "wb") as f: + f.write(b"idx") + os.chmod(pack_file, stat.S_IREAD) + + clone_target_Existed_and_nonempty = [] + + def side_effect(cmd, **kwargs): + proc = MagicMock() + if "remote" in cmd and "set-url" in cmd: + proc.returncode = 0 + proc.stdout = "" + elif "fetch" in cmd: + proc.returncode = 128 + proc.stderr = "fatal: bad object HEAD" + elif "clone" in cmd: + clone_exists = os.path.exists(target_dir) and bool(os.listdir(target_dir)) + clone_target_Existed_and_nonempty.append(clone_exists) + os.makedirs(os.path.join(target_dir, ".git"), exist_ok=True) + proc.returncode = 0 + proc.stdout = "" + elif "rev-parse" in cmd: + proc.returncode = 0 + proc.stdout = "recovered_sha_999\n" + else: + proc.returncode = 0 + proc.stdout = "" + return proc + + mock_run.side_effect = side_effect + try: + res = shallow_clone_repo("https://github.com/org/repo", "main", target_dir=target_dir) + self.assertIsNone(res.error) + self.assertEqual(res.commit_sha, "recovered_sha_999") + self.assertEqual(clone_target_Existed_and_nonempty, [False]) + finally: + cleanup_repo_dir(target_dir) + @patch("requests.get") def test_check_github_rate_limit_success(self, mock_get): mock_resp = MagicMock() From 4f114aac2de2b7852b16a212a244eae0b0d01835 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Wed, 11 Nov 2026 16:25:59 -0600 Subject: [PATCH 2/2] fix(ui,config): show pgvector FTS on Overview, ignore placeholder API keys, and detect Windows RAM --- app/services/database/connection.py | 43 ++++++++++++++++++++++++---- frontend/src/Overview.tsx | 8 +++++- frontend/src/tests/Overview.test.tsx | 1 + 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/app/services/database/connection.py b/app/services/database/connection.py index a223718..8430c8e 100644 --- a/app/services/database/connection.py +++ b/app/services/database/connection.py @@ -206,10 +206,37 @@ def detect_system_resources() -> Dict[str, Any]: if hasattr(os, "sysconf") and "SC_PAGE_SIZE" in os.sysconf_names and "SC_PHYS_PAGES" in os.sysconf_names: mem_bytes = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") mem_gb = round(mem_bytes / (1024 ** 3), 1) + elif os.name == "nt": + import ctypes + + class _MEMORYSTATUSEX(ctypes.Structure): + _fields_ = [ + ("dwLength", ctypes.c_ulong), + ("dwMemoryLoad", ctypes.c_ulong), + ("ullTotalPhys", ctypes.c_ulonglong), + ("ullAvailPhys", ctypes.c_ulonglong), + ("ullTotalPageFile", ctypes.c_ulonglong), + ("ullAvailPageFile", ctypes.c_ulonglong), + ("ullTotalVirtual", ctypes.c_ulonglong), + ("ullAvailVirtual", ctypes.c_ulonglong), + ("sullAvailExtendedVirtual", ctypes.c_ulonglong), + ] + + stat = _MEMORYSTATUSEX() + stat.dwLength = ctypes.sizeof(_MEMORYSTATUSEX) + if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat)): + mem_gb = round(stat.ullTotalPhys / (1024 ** 3), 1) except Exception: pass return {"cpus": cpus, "memory_gb": mem_gb} +_PLACEHOLDER_API_KEYS = {"dummy", "your-corporate-api-key", "your-api-key", "none", "null"} + +def _is_placeholder_api_key(val: Optional[str]) -> bool: + if not val: + return True + return val.strip().lower() in _PLACEHOLDER_API_KEYS + def _resolve_default_embedding_config(conn: Optional[Any] = None) -> Dict[str, Any]: sys_res = detect_system_resources() default_threads = min(2, max(1, sys_res["cpus"])) @@ -229,11 +256,12 @@ def _resolve_default_embedding_config(conn: Optional[Any] = None) -> Dict[str, A or os.getenv("LITELLM_URL") or "http://litellm:4000/v1" ).strip() - litellm_api_key = ( + raw_api_key = ( os.getenv("OPENAI_API_KEY") or os.getenv("LITELLM_API_KEY") - or "dummy" + or "" ).strip() + litellm_api_key = "" if _is_placeholder_api_key(raw_api_key) else raw_api_key vision_ocr_model = os.getenv("VISION_OCR_MODEL", "").strip() chat_model = os.getenv("CHAT_MODEL", "").strip() @@ -291,6 +319,8 @@ def get_embedding_db_config() -> Dict[str, Any]: allow_fallback = default_cfg["allow_fallback"] resolved_url = (litellm_url or default_cfg["litellm_url"]).strip() + raw_db_key = (litellm_api_key or "").strip() + effective_key = ("" if _is_placeholder_api_key(raw_db_key) else raw_db_key) or default_cfg["litellm_api_key"] return { "provider": (provider or default_cfg["provider"]).lower().strip(), "dense_model": (dense_model or default_cfg["dense_model"]).strip(), @@ -299,7 +329,7 @@ def get_embedding_db_config() -> Dict[str, Any]: "batch_size": max(1, batch_size), "openai_api_url": resolved_url, "litellm_url": resolved_url, - "litellm_api_key": litellm_api_key or default_cfg["litellm_api_key"], + "litellm_api_key": effective_key, "vision_ocr_model": (vision_ocr_model or default_cfg["vision_ocr_model"]).strip(), "chat_model": (chat_model or default_cfg["chat_model"]).strip(), "allow_fallback": allow_fallback, @@ -384,8 +414,11 @@ def get_ai_gateway_config() -> Dict[str, Any]: or os.getenv("LITELLM_URL") or "http://litellm:4000/v1" ).strip() - if not api_key: - api_key = (os.getenv("OPENAI_API_KEY") or os.getenv("LITELLM_API_KEY") or "").strip() + if not api_key or _is_placeholder_api_key(api_key): + env_key = (os.getenv("OPENAI_API_KEY") or os.getenv("LITELLM_API_KEY") or "").strip() + api_key = "" if _is_placeholder_api_key(env_key) else env_key + else: + api_key = api_key.strip() if not chat_model: chat_model = os.getenv("CHAT_MODEL", "").strip() if not vision_model: diff --git a/frontend/src/Overview.tsx b/frontend/src/Overview.tsx index 1575418..514630c 100644 --- a/frontend/src/Overview.tsx +++ b/frontend/src/Overview.tsx @@ -85,7 +85,13 @@ export default function Overview({ stats, refreshStats }: { stats: Stats | null,
Sparse BM25 Model: - {stats.sparse_model ? `${stats.sparse_model} (FastEmbed)` : 'Qdrant/bm25 (FastEmbed)'} + + {stats.vector_store_provider === 'pgvector' || stats.vector_store_provider === 'postgres' || stats.vector_store_provider === 'postgresql' + ? 'PostgreSQL Full-Text Search (tsvector + GIN)' + : stats.sparse_model + ? `${stats.sparse_model} (FastEmbed)` + : 'Qdrant/bm25 (FastEmbed)'} +
Retrieval Strategy: diff --git a/frontend/src/tests/Overview.test.tsx b/frontend/src/tests/Overview.test.tsx index 332f491..4a149b8 100644 --- a/frontend/src/tests/Overview.test.tsx +++ b/frontend/src/tests/Overview.test.tsx @@ -73,6 +73,7 @@ describe('Overview Component', () => { expect(screen.getByText(/PGVECTOR \(PostgreSQL\) Vectors/i)).toBeInTheDocument(); expect(screen.getByText(/PostgreSQL \(pgvector\)/i)).toBeInTheDocument(); + expect(screen.getByText('PostgreSQL Full-Text Search (tsvector + GIN)')).toBeInTheDocument(); expect(screen.getByText('Dense HNSW + Full-Text Weighted Fusion')).toBeInTheDocument(); });