Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions REQUIREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down Expand Up @@ -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`
Expand All @@ -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`
Expand All @@ -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`
Expand Down
43 changes: 38 additions & 5 deletions app/services/database/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]))
Expand All @@ -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()
Expand Down Expand Up @@ -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(),
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
46 changes: 37 additions & 9 deletions app/services/git_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,20 +235,29 @@ 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)
elif target_dir and os.path.exists(repo_dir):
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",
Expand All @@ -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
Expand All @@ -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)}")

Expand Down Expand Up @@ -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}")
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/Overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,13 @@ export default function Overview({ stats, refreshStats }: { stats: Stats | null,
</div>
<div className="spec-row">
<span>Sparse BM25 Model:</span>
<code>{stats.sparse_model ? `${stats.sparse_model} (FastEmbed)` : 'Qdrant/bm25 (FastEmbed)'}</code>
<code>
{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)'}
</code>
</div>
<div className="spec-row">
<span>Retrieval Strategy:</span>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/tests/Overview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
58 changes: 58 additions & 0 deletions tests/backend/test_git_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading