@@ -341,3 +341,344 @@ def delete_analysis(database_path: str, analysis_id: int) -> None:
341341 conn .commit ()
342342 finally :
343343 conn .close ()
344+
345+
346+ # ============================================================================
347+ # Project Registry Database Operations
348+ # ============================================================================
349+
350+ # Default projects directory
351+ PROJECTS_DIR = os .path .expanduser ("~/.picocode/projects" )
352+
353+ # Retry configuration for database operations
354+ DB_RETRY_COUNT = 3
355+ DB_RETRY_DELAY = 0.1 # seconds
356+
357+
358+ def _ensure_projects_dir ():
359+ """Ensure projects directory exists."""
360+ try :
361+ os .makedirs (PROJECTS_DIR , exist_ok = True )
362+ except Exception as e :
363+ _LOG .error (f"Failed to create projects directory { PROJECTS_DIR } : { e } " )
364+ raise
365+
366+
367+ def _retry_on_db_locked (func , * args , max_retries = DB_RETRY_COUNT , ** kwargs ):
368+ """Retry a database operation if it's locked."""
369+ import time
370+ last_error = None
371+
372+ for attempt in range (max_retries ):
373+ try :
374+ return func (* args , ** kwargs )
375+ except sqlite3 .OperationalError as e :
376+ if "database is locked" in str (e ).lower () and attempt < max_retries - 1 :
377+ last_error = e
378+ time .sleep (DB_RETRY_DELAY * (2 ** attempt )) # Exponential backoff
379+ continue
380+ raise
381+ except Exception as e :
382+ raise
383+
384+ if last_error :
385+ raise last_error
386+
387+
388+ def _get_project_id (project_path : str ) -> str :
389+ """Generate a stable project ID from the project path."""
390+ import hashlib
391+ return hashlib .sha256 (project_path .encode ()).hexdigest ()[:16 ]
392+
393+
394+ def _get_project_db_path (project_id : str ) -> str :
395+ """Get the database path for a project."""
396+ _ensure_projects_dir ()
397+ return os .path .join (PROJECTS_DIR , f"{ project_id } .db" )
398+
399+
400+ def _get_projects_registry_path () -> str :
401+ """Get the path to the projects registry database."""
402+ _ensure_projects_dir ()
403+ return os .path .join (PROJECTS_DIR , "registry.db" )
404+
405+
406+ def _init_registry_db ():
407+ """Initialize the projects registry database with proper configuration."""
408+ registry_path = _get_projects_registry_path ()
409+
410+ def _init ():
411+ conn = _get_connection (registry_path )
412+ try :
413+ cur = conn .cursor ()
414+ cur .execute (
415+ """
416+ CREATE TABLE IF NOT EXISTS projects (
417+ id TEXT PRIMARY KEY,
418+ name TEXT NOT NULL,
419+ path TEXT NOT NULL UNIQUE,
420+ database_path TEXT NOT NULL,
421+ created_at TEXT DEFAULT (datetime('now')),
422+ last_indexed_at TEXT,
423+ status TEXT DEFAULT 'created',
424+ settings TEXT
425+ )
426+ """
427+ )
428+ conn .commit ()
429+ except Exception as e :
430+ _LOG .error (f"Failed to initialize registry database: { e } " )
431+ raise
432+ finally :
433+ conn .close ()
434+
435+ try :
436+ _retry_on_db_locked (_init )
437+ except Exception as e :
438+ _LOG .error (f"Failed to initialize registry after retries: { e } " )
439+ raise
440+
441+
442+ def create_project (project_path : str , name : Optional [str ] = None ) -> Dict [str , Any ]:
443+ """
444+ Create a new project entry with its own database.
445+
446+ Args:
447+ project_path: Absolute path to the project directory
448+ name: Optional project name (defaults to directory name)
449+
450+ Returns:
451+ Project metadata dictionary
452+
453+ Raises:
454+ ValueError: If project path is invalid
455+ RuntimeError: If database operations fail
456+ """
457+ try :
458+ _init_registry_db ()
459+ except Exception as e :
460+ _LOG .error (f"Failed to initialize registry: { e } " )
461+ raise RuntimeError (f"Database initialization failed: { e } " )
462+
463+ # Validate and normalize path
464+ if not project_path or not isinstance (project_path , str ):
465+ raise ValueError ("Project path must be a non-empty string" )
466+
467+ # Check for path traversal attempts
468+ if ".." in project_path or project_path .startswith ("~" ):
469+ raise ValueError ("Path traversal not allowed in project path" )
470+
471+ try :
472+ project_path = os .path .abspath (os .path .realpath (project_path ))
473+ except Exception as e :
474+ raise ValueError (f"Invalid project path: { e } " )
475+
476+ try :
477+ path_exists = os .path .exists (project_path ) # nosec
478+ if not path_exists :
479+ raise ValueError (f"Project path does not exist" )
480+
481+ is_directory = os .path .isdir (project_path ) # nosec
482+ if not is_directory :
483+ raise ValueError (f"Project path is not a directory" )
484+ except (OSError , ValueError ) as e :
485+ if isinstance (e , ValueError ):
486+ raise
487+ raise ValueError (f"Cannot access project path" )
488+
489+ project_id = _get_project_id (project_path )
490+ db_path = _get_project_db_path (project_id )
491+
492+ if not name :
493+ name = os .path .basename (project_path )
494+
495+ if name and len (name ) > 255 :
496+ name = name [:255 ]
497+
498+ registry_path = _get_projects_registry_path ()
499+
500+ def _create ():
501+ conn = _get_connection (registry_path )
502+ try :
503+ cur = conn .cursor ()
504+
505+ cur .execute ("SELECT * FROM projects WHERE path = ?" , (project_path ,))
506+ existing = cur .fetchone ()
507+ if existing :
508+ _LOG .info (f"Project already exists: { project_path } " )
509+ return dict (existing )
510+
511+ cur .execute (
512+ """
513+ INSERT INTO projects (id, name, path, database_path, status)
514+ VALUES (?, ?, ?, ?, 'created')
515+ """ ,
516+ (project_id , name , project_path , db_path )
517+ )
518+ conn .commit ()
519+
520+ try :
521+ init_db (db_path )
522+ _LOG .info (f"Created project { project_id } at { db_path } " )
523+ except Exception as e :
524+ _LOG .error (f"Failed to initialize project database: { e } " )
525+ cur .execute ("DELETE FROM projects WHERE id = ?" , (project_id ,))
526+ conn .commit ()
527+ raise RuntimeError (f"Failed to initialize project database: { e } " )
528+
529+ cur .execute ("SELECT * FROM projects WHERE id = ?" , (project_id ,))
530+ row = cur .fetchone ()
531+ return dict (row ) if row else None
532+ finally :
533+ conn .close ()
534+
535+ try :
536+ return _retry_on_db_locked (_create )
537+ except Exception as e :
538+ _LOG .error (f"Failed to create project: { e } " )
539+ raise
540+
541+
542+ def get_project (project_path : str ) -> Optional [Dict [str , Any ]]:
543+ """Get project metadata by path."""
544+ _init_registry_db ()
545+ project_path = os .path .abspath (project_path )
546+
547+ registry_path = _get_projects_registry_path ()
548+
549+ def _get ():
550+ conn = _get_connection (registry_path )
551+ try :
552+ cur = conn .cursor ()
553+ cur .execute ("SELECT * FROM projects WHERE path = ?" , (project_path ,))
554+ row = cur .fetchone ()
555+ return dict (row ) if row else None
556+ finally :
557+ conn .close ()
558+
559+ return _retry_on_db_locked (_get )
560+
561+
562+ def get_project_by_id (project_id : str ) -> Optional [Dict [str , Any ]]:
563+ """Get project metadata by ID."""
564+ _init_registry_db ()
565+
566+ registry_path = _get_projects_registry_path ()
567+
568+ def _get ():
569+ conn = _get_connection (registry_path )
570+ try :
571+ cur = conn .cursor ()
572+ cur .execute ("SELECT * FROM projects WHERE id = ?" , (project_id ,))
573+ row = cur .fetchone ()
574+ return dict (row ) if row else None
575+ finally :
576+ conn .close ()
577+
578+ return _retry_on_db_locked (_get )
579+
580+
581+ def list_projects () -> List [Dict [str , Any ]]:
582+ """List all registered projects."""
583+ _init_registry_db ()
584+
585+ registry_path = _get_projects_registry_path ()
586+
587+ def _list ():
588+ conn = _get_connection (registry_path )
589+ try :
590+ cur = conn .cursor ()
591+ cur .execute ("SELECT * FROM projects ORDER BY created_at DESC" )
592+ rows = cur .fetchall ()
593+ return [dict (row ) for row in rows ]
594+ finally :
595+ conn .close ()
596+
597+ return _retry_on_db_locked (_list )
598+
599+
600+ def update_project_status (project_id : str , status : str , last_indexed_at : Optional [str ] = None ):
601+ """Update project indexing status."""
602+ _init_registry_db ()
603+
604+ registry_path = _get_projects_registry_path ()
605+
606+ def _update ():
607+ conn = _get_connection (registry_path )
608+ try :
609+ cur = conn .cursor ()
610+ if last_indexed_at :
611+ cur .execute (
612+ "UPDATE projects SET status = ?, last_indexed_at = ? WHERE id = ?" ,
613+ (status , last_indexed_at , project_id )
614+ )
615+ else :
616+ cur .execute (
617+ "UPDATE projects SET status = ? WHERE id = ?" ,
618+ (status , project_id )
619+ )
620+ conn .commit ()
621+ finally :
622+ conn .close ()
623+
624+ _retry_on_db_locked (_update )
625+
626+
627+ def update_project_settings (project_id : str , settings : Dict [str , Any ]):
628+ """Update project settings (stored as JSON)."""
629+ import json
630+ _init_registry_db ()
631+
632+ registry_path = _get_projects_registry_path ()
633+
634+ def _update ():
635+ conn = _get_connection (registry_path )
636+ try :
637+ cur = conn .cursor ()
638+ cur .execute (
639+ "UPDATE projects SET settings = ? WHERE id = ?" ,
640+ (json .dumps (settings ), project_id )
641+ )
642+ conn .commit ()
643+ finally :
644+ conn .close ()
645+
646+ _retry_on_db_locked (_update )
647+
648+
649+ def delete_project (project_id : str ):
650+ """Delete a project and its database."""
651+ _init_registry_db ()
652+
653+ project = get_project_by_id (project_id )
654+ if not project :
655+ raise ValueError (f"Project not found: { project_id } " )
656+
657+ db_path = project .get ("database_path" )
658+ if db_path and os .path .exists (db_path ):
659+ try :
660+ os .remove (db_path )
661+ except Exception :
662+ pass
663+
664+ registry_path = _get_projects_registry_path ()
665+
666+ def _delete ():
667+ conn = _get_connection (registry_path )
668+ try :
669+ cur = conn .cursor ()
670+ cur .execute ("DELETE FROM projects WHERE id = ?" , (project_id ,))
671+ conn .commit ()
672+ finally :
673+ conn .close ()
674+
675+ _retry_on_db_locked (_delete )
676+
677+
678+ def get_or_create_project (project_path : str , name : Optional [str ] = None ) -> Dict [str , Any ]:
679+ """Get existing project or create new one."""
680+ project = get_project (project_path )
681+ if project :
682+ return project
683+ return create_project (project_path , name )
684+
0 commit comments