
    `gj_                    (   U d Z ddlmZ ddlZddlZddlZddlZddlZddlZddl	m
Z
mZ ddlmZ ddlmZmZmZ ddlmZmZ ddlmZ d:d	Zd
Z ej4                  d      Zd;dZd<dZd=dZd>dZd?dZ  e!       Z"de#d<   d@dAdZ$ejJ                  d@dBd       Z&dZ'dCdZ(e
 G d d             Z)e
 G d d             Z*dDdZ+dEdZ,dFdZ-dGdZ.dddddddd	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 dHd Z/d!d"	 	 	 	 	 dId#Z0	 	 	 	 	 	 dJd$Z1dddddd%	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 dKd&Z2dd!d'	 	 	 	 	 	 	 	 	 	 	 dLd(Z3dMd)Z4	 	 	 	 	 	 	 	 dNd*Z5dMd+Z6dOd,Z7dOd-Z8dOd.Z9d/Z:dPd0Z;dQd1Z<d!d2	 	 	 	 	 	 	 dRd3Z=dSd4Z>d!d"	 	 	 	 	 	 	 dTd5Z? ej4                  d6      Z@d7d8dUd9ZAy)Vu  Per-profile first-class Project store.

A **Project** is a human-named, multi-folder workspace. Unlike the desktop's
old inferred "workspaces" (derived from each session's ``cwd`` + a git probe)
and unlike kanban's self-generated worktrees, a Project is an explicit,
persisted entity the user creates and names. It anchors:

- **Desktop session grouping** — a session belongs to a project when its
  ``cwd`` lives under one of the project's folders (longest-prefix match).
- **Kanban task worktrees** — a task linked to a project creates its worktree
  under the project's primary repo with a deterministic branch name, instead
  of the random ``wt/<task-id>`` fallback.

Scope: **per-profile**, stored at ``$HERMES_HOME/projects.db`` (resolved via
``get_hermes_home()``), mirroring sessions / config / cron. This deliberately
differs from kanban, whose board DB is root-anchored and shared across
profiles. A Project may *bind* a kanban board (``board_slug``) so the two
systems agree on the repo + branch convention without merging their stores.

The schema is intentionally small and additive: column additions go through
:func:`_add_column_if_missing` so opening an old DB is always safe.
    )annotationsN)	dataclassfield)Path)IterableListOptional)add_column_if_missing	write_txnget_hermes_homec                     t               dz  S )zThe per-profile projects DB path (``$HERMES_HOME/projects.db``).

    Profile-aware: ``get_hermes_home()`` already points at the active profile's
    home. Tests pass an explicit ``db_path`` to :func:`connect`.
    projects.dbr        I/root/.hermes/venv/lib/python3.12/site-packages/hermes_cli/projects_db.pyprojects_db_pathr   ,   s     },,r   a  
CREATE TABLE IF NOT EXISTS projects (
    id            TEXT PRIMARY KEY,
    slug          TEXT NOT NULL UNIQUE,
    name          TEXT NOT NULL,
    description   TEXT,
    icon          TEXT,
    color         TEXT,
    board_slug    TEXT,
    primary_path  TEXT,
    created_at    INTEGER NOT NULL,
    archived      INTEGER NOT NULL DEFAULT 0
);

CREATE TABLE IF NOT EXISTS project_folders (
    project_id  TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    path        TEXT NOT NULL,
    label       TEXT,
    is_primary  INTEGER NOT NULL DEFAULT 0,
    added_at    INTEGER NOT NULL,
    PRIMARY KEY (project_id, path)
);

CREATE INDEX IF NOT EXISTS idx_project_folders_path
    ON project_folders(path);

CREATE TABLE IF NOT EXISTS project_meta (
    key    TEXT PRIMARY KEY,
    value  TEXT
);

-- Git repos found by scanning the filesystem (desktop "repo-first" discovery).
-- Cached here so the overview is instant after the first scan instead of
-- re-walking the disk every time the Projects view opens.
CREATE TABLE IF NOT EXISTS discovered_repos (
    root          TEXT PRIMARY KEY,
    label         TEXT,
    last_seen     INTEGER NOT NULL
);
z^[a-z0-9][a-z0-9\-_]{0,63}$c                    t        | xs d      j                         j                         }t        j                  dd|      j                  d      }|dd j                  d      }|xs dS )z8Derive a slug candidate from a human name (best-effort). z
[^a-z0-9]+--_N@   project)strstriplowerresub)namess     r   _slugifyr!   n   s\    DJB%%'A
}c1%++D1A	#2TA>	r   c                    | yt        |       j                         j                         }|syt        j	                  |      st        d| d      |S )z>Lowercase + strip a slug; validate; return ``None`` for empty.Nzinvalid project slug zc: must be 1-64 chars, lowercase alphanumerics / hyphens / underscores, not starting with '-' or '_')r   r   r   _SLUG_REmatch
ValueError)slugr    s     r   normalize_slugr'   v   sZ    |D	!A>>!#D8 , 
 	

 Hr   c                 2    dt        j                  d      z   S )Np_   )secrets	token_hexr   r   r   _new_project_idr-      s    '##A&&&r   c                 <    t        t        j                               S N)inttimer   r   r   _nowr2      s    tyy{r   c                    t         j                  j                  t         j                  j                  t	        |       j                                     }|j                  d      xs |S )zEAbsolute, user-expanded, separator-normalized path (no trailing sep)./\)ospathabspath
expanduserr   r   rstrip)r6   ps     r   _normalize_pathr;      sA    
**3t9??+<=>A88E?ar   zset[str]_INITIALIZED_PATHSc                   | | n	t               }|j                  j                  dd       t        |j	                               }t        j                  t        |            }	 t
        j                  |_        ddl	m
}  ||d       |j                  d       |t        vr5|j                  t               t        |       t        j!                  |       |S # t"        $ r |j%                           w xY w)a  Open (and initialize if needed) the per-profile projects DB.

    WAL with DELETE fallback for network filesystems (shared helper from
    ``hermes_state``). Schema init is idempotent (``CREATE TABLE IF NOT
    EXISTS`` + additive migrations) and cached per-path per-process.
    T)parentsexist_okr   )apply_wal_with_fallbackr   )db_labelzPRAGMA foreign_keys=ON)r   parentmkdirr   resolvesqlite3connectRowrow_factoryhermes_stater@   executer<   executescript
SCHEMA_SQL_migrate_add_optional_columnsadd	Exceptionclose)db_pathr6   resolvedconnr@   s        r   rF   rF      s     )7/?/ADKKdT24<<>"H??3t9%D";;8}=-.--z*)$/""8, K  

s   $A3C C4c              #     K   t        |       }	 | 	 |j                          y# t        $ r Y yw xY w# 	 |j                          w # t        $ r Y w w xY wxY ww)au  Open a projects DB connection and guarantee it is closed on exit.

    sqlite3's connection context manager only commits/rollbacks; it does NOT
    close the file descriptor. Long-lived processes (gateway, dashboard) route
    many project operations through ``connect()``; without closing, FDs to
    ``projects.db`` accumulate. Mirrors ``kanban_db.connect_closing``.
    )rQ   N)rF   rP   rO   )rQ   rS   s     r   connect_closingrU      s[      7#D
	JJL 			JJL 		sR   A5 & A	2A2AAAA	AAAAA)
board_slugprimary_pathiconcolorc                    | j                  d      D ch c]  }|d   	 }}t        D ]  }||vst        | d|| d        yc c}w )zCAdd columns introduced after v1 to legacy DBs (safe on every open).zPRAGMA table_info(projects)r   projectsz TEXTN)rJ   _OPTIONAL_PROJECT_COLUMNS_add_column_if_missing)rS   rowcolscols       r   rM   rM      sU    #'<<0M#NOCCKODO( Id?"4SSE-HI Ps   Ac                  J    e Zd ZU ded<   dZded<   dZded<   d	Zd
ed<   ddZy)ProjectFolderr   r6   NOptional[str]labelFbool
is_primaryr   r0   added_atc                r    | j                   | j                  t        | j                        | j                  dS )Nr6   rd   rf   rg   )r6   rd   re   rf   rg   )selfs    r   to_dictzProjectFolder.to_dict   s-    IIZZt/	
 	
r   returndict)__name__
__module____qualname____annotations__rd   rf   rg   rk   r   r   r   rb   rb      s*    
IE=JHc
r   rb   c                      e Zd ZU ded<   ded<   ded<   ded<   dZded	<   dZded
<   dZded<   dZded<   dZded<   dZ	ded<    e
e      Zded<   ddZy)Projectr   idr&   r   r0   
created_atNrc   descriptionrX   rY   rV   rW   Fre   archived)default_factoryList[ProjectFolder]foldersc                N   | j                   | j                  | j                  | j                  | j                  | j
                  | j                  | j                  t        | j                        | j                  | j                  D cg c]  }|j                          c}dS c c}w )N)ru   r&   r   rw   rX   rY   rV   rW   rx   rv   r{   )ru   r&   r   rw   rX   rY   rV   rW   re   rx   rv   r{   rk   )rj   fs     r   rk   zProject.to_dict   sx    ''IIII++IIZZ// --T]]+//-1\\:		:
 	
 ;s   B"rl   )ro   rp   rq   rr   rw   rX   rY   rV   rW   rx   r   listr{   rk   r   r   r   rt   rt      sg    G
I
IO!%K%D-E= $J$"&L-&Hd#(#>G >
r   rt   c                    | j                         }t        | d   | d   | d   | d   d|v r| d   nd d|v r| d   nd d|v r| d   nd d|v r| d   nd d	|v r| d	   nd d
|v rt        | d
         
      S d
      S )Nru   r&   r   rv   rw   rX   rY   rV   rW   rx   F)
ru   r&   r   rv   rw   rX   rY   rV   rW   rx   )keysrt   re   )r^   r   s     r   _project_from_rowr   	  s    88:Dt9[[|$*74*?C&T"dNS[%oc'l4(4(<3|$$,:d,BS(*4*<c*o&  CH r   c                    | j                  d|f      j                         }|D cg c]&  }t        |d   |d   t        |d         |d         ( c}S c c}w )NzySELECT path, label, is_primary, added_at FROM project_folders WHERE project_id = ? ORDER BY is_primary DESC, added_at ASCr6   rd   rf   rg   ri   )rJ   fetchallrb   re   )rS   
project_idrowsrs       r   _load_foldersr     sq    <<	F	 hj	 	   	6G*AlO,z]		
  s   +Ac                <    t        | |j                        |_        |S r/   )r   ru   r{   )rS   r   s     r   _attach_foldersr   *  s    #D'**5GONr   c                    |}d}|}| j                  d|f      j                         O|dz  }d| }|ddt        |      z
   j                  d      |z   }| j                  d|f      j                         O|S )z=Return ``candidate`` or ``candidate-2``, ``-3`` ... if taken.   z%SELECT 1 FROM projects WHERE slug = ?Nr   r   r   )rJ   fetchonelenr9   )rS   	candidatebasenr&   suffixs         r   _unique_slugr   4  s    D	AD
,,/$hj 	
QQC'rCK'(006? ,,/$hj Kr   )r&   r{   rW   rw   rX   rY   rV   c               j   t        |xs d      j                         }|st        d      |rt        |      n
t	        |      }	t               }
t               }g }|xs g D ]&  }t        |      }|s||vs|j                  |       ( |rt        |      nd}|r||vr|j                  d|       ||r|d   }t        |       5  t        | |	      }| j                  d|
||||||rt        |      nd||f	       |D ]   }| j                  d|
|d||k(  rdnd|f       " 	 ddd       |
S # 1 sw Y   |
S xY w)zCreate a project and return its id.

    ``folders`` are normalized to absolute paths. If ``primary_path`` is given
    it is added to the folder set (if not already present) and marked primary;
    otherwise the first folder becomes primary.
    r   project name must not be emptyNr   zINSERT INTO projects (id, slug, name, description, icon, color, board_slug,  primary_path, created_at, archived) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)zbINSERT INTO project_folders (project_id, path, label, is_primary, added_at) VALUES (?, ?, ?, ?, ?)r   )r   r   r%   r'   r!   r-   r2   r;   appendinsertr   r   rJ   )rS   r   r&   r{   rW   rw   rX   rY   rV   slug_candidatepidnowfolder_pathsr}   normprimaryuniquer6   s                     r   create_projectr   B  se   $ tzr?  "D9::-1^D)x~N

C
&C L] &q!D,%&
 0<ol+G7,.Aw'<q/	4 dN34
 .8z*d
	
" ! 	DLL) dDtw!AsC		'4 J54 Js   AD((D2F)include_archivedc          	         d}|s|dz  }|dz  }| j                  |      j                         }|D cg c]  }t        | t        |             c}S c c}w )NzSELECT * FROM projectsz WHERE archived = 0z ORDER BY created_at ASC)rJ   r   r   r   )rS   r   sqlr   r   s        r   list_projectsr     sZ     #C$$%%C<<%%'DAEFAOD"3A"67FFFs   Ac                    | j                  d|f      j                         }|8| j                  dt        |      j                         f      j                         }|yt	        | t        |            S )z,Look up a project by id first, then by slug.z#SELECT * FROM projects WHERE id = ?Nz%SELECT * FROM projects WHERE slug = ?)rJ   r   r   r   r   r   )rS   
id_or_slugr^   s      r   get_projectr     st     ,,-
}hj  {ll3c*o6K6K6M5O

(* 	 {4!23!788r   )r   rw   rX   rY   rV   c                  g }g }|Ht        |      j                         }	|	st        d      |j                  d       |j                  |	       |"|j                  d       |j                  |       |&|j                  d       |j                  |xs d       |&|j                  d       |j                  |xs d       |=|j                  d       |j                  |j                         rt	        |      nd       |sy|j                  |       t        |       5  | j                  d	d
j                  |       d|      }
ddd       
j                  dkD  S # 1 sw Y   xY w)u  Patch top-level project fields. Only provided fields change.

    ``icon``, ``color``, and ``board_slug`` accept an empty string to clear
    (store NULL) — passing ``None`` leaves the field untouched, so callers that
    want to clear must send ``""``.
    Nr   zname = ?zdescription = ?zicon = ?z	color = ?zboard_slug = ?FzUPDATE projects SET z, z WHERE id = ?r   )	r   r   r%   r   r'   r   rJ   joinrowcount)rS   r   r   rw   rX   rY   rV   setsparamsr   curs              r   update_projectr     sC     DFIOO=>>Ja%&k"Jdld#K emt$$%J4D4D4FnZ0DQ
MM*	4 
ll"499T?"3=A6

 <<!	
 
s   !&EE')rd   rf   c                  t        |      }|st        d      t        | |      t        d|       t               }t	        |       5  | j                  d||||f       || j                  d|||f       |rt        | ||       n0| j                  d|f      j                         }|t        | ||       ddd       |S # 1 sw Y   |S xY w)zAdd a folder to a project. Returns the normalized path.

    When ``is_primary`` is set, the folder becomes the project's primary repo
    (the previous primary is demoted, and ``projects.primary_path`` updates).
    zfolder path must not be emptyNzno such project: zlINSERT OR IGNORE INTO project_folders (project_id, path, label, is_primary, added_at) VALUES (?, ?, ?, 0, ?)zFUPDATE project_folders SET label = ? WHERE project_id = ? AND path = ?zESELECT 1 FROM project_folders WHERE project_id = ? AND is_primary = 1)r;   r%   r   r2   r   rJ   _set_primary_lockedr   )rS   r   r6   rd   rf   r   r   existing_primarys           r   
add_folderr     s     4 D8994$,,ZL9::
&C	4 <% uc*		
 LL4
D)
 j$7  $||:  hj	 
  '#D*d;/<0 K1<0 Ks   A.C  C
c                   t        |      }t        |       5  | j                  d||f      j                         }| j                  d||f      }|R|d   rM| j                  d|f      j                         }|r|d   nd}|rt	        | ||       n| j                  d|f       ddd       j
                  dkD  S # 1 sw Y   xY w)	zCRemove a folder from a project. Repoints primary if it was primary.zHSELECT is_primary FROM project_folders WHERE project_id = ? AND path = ?z=DELETE FROM project_folders WHERE project_id = ? AND path = ?Nrf   zSSELECT path FROM project_folders WHERE project_id = ? ORDER BY added_at ASC LIMIT 1r6   z4UPDATE projects SET primary_path = NULL WHERE id = ?r   )r;   r   rJ   r   r   r   )rS   r   r6   r   was_primaryr   nxtnew_primarys           r   remove_folderr     s    4 D	4 ll0
 (*	 	
 llK
 "{<'@,,0 hj	 
 *-#f+$K#D*kBJM)0 <<!1 s   BB99Cc                z    | j                  d|f       | j                  d||f       | j                  d||f       y)z:Set the primary folder (caller already holds a write txn).z>UPDATE project_folders SET is_primary = 0 WHERE project_id = ?zKUPDATE project_folders SET is_primary = 1 WHERE project_id = ? AND path = ?z1UPDATE projects SET primary_path = ? WHERE id = ?N)rJ   )rS   r   r6   s      r   r   r     sL     	LLH	 	LL	,	T
 	LL;	zr   c                    t        |      }t        |       5  | j                  d||f      j                         }|
	 d d d        yt	        | ||       d d d        y# 1 sw Y   yxY w)Nz?SELECT 1 FROM project_folders WHERE project_id = ? AND path = ?FT)r;   r   rJ   r   r   )rS   r   r6   r   existss        r   set_primaryr   -  st    4 D	4 4M
 (* 	 >4 4 	D*d34 4 s   &AAA%c                    t        |       5  | j                  d|f      }d d d        j                  dkD  S # 1 sw Y   xY w)Nz-UPDATE projects SET archived = 1 WHERE id = ?r   r   rJ   r   rS   r   r   s      r   archive_projectr   :  D    	4 
ll;j]

 <<!	
 
	   7A c                    t        |       5  | j                  d|f      }d d d        j                  dkD  S # 1 sw Y   xY w)Nz-UPDATE projects SET archived = 0 WHERE id = ?r   r   r   s      r   restore_projectr   B  r   r   c                    t        |       5  | j                  d|f      }ddd       j                  dkD  S # 1 sw Y   xY w)z0Hard-delete a project and its folders (cascade).z!DELETE FROM projects WHERE id = ?Nr   r   r   s      r   delete_projectr   J  sD    	4 Oll>NO<<!O Or   	active_idc                    t        |       5  || j                  dt        f       n| j                  dt        |f       ddd       y# 1 sw Y   yxY w)z9Set (or clear, when ``None``) the active project pointer.Nz&DELETE FROM project_meta WHERE key = ?ziINSERT INTO project_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value)r   rJ   _ACTIVE_META_KEY)rS   r   s     r   
set_activer   Y  sQ    	4 LLADTCVWLLH!:.	  s   3AAc                ^    | j                  dt        f      j                         }|r|d   S d S )Nz,SELECT value FROM project_meta WHERE key = ?value)rJ   r   r   )rS   r^   s     r   get_active_idr   f  s6    
,,69I8Khj  3w<(D(r   )replacec               t   t               }g }|D ]L  \  }}t        |      }|s|j                  ||xs# t        j                  j                  |      xs ||f       N t        |       5  |r| j                  d       |r| j                  d|       ddd       t        |      S # 1 sw Y   t        |      S xY w)a  Persist scanned git repo roots into the cache.

    ``repos`` is an iterable of ``(root, label)``. Roots are normalized; the
    label falls back to the basename. Returns the number of rows written.

    When ``replace`` is true, this is the authoritative result of a fresh disk
    scan: delete stale rows first so old eval/worktree noise disappears instead
    of living forever in the cache.
    zDELETE FROM discovered_reposzINSERT INTO discovered_repos (root, label, last_seen) VALUES (?, ?, ?) ON CONFLICT(root) DO UPDATE SET label = excluded.label, last_seen = excluded.last_seenN)
r2   r;   r   r5   r6   basenamer   rJ   executemanyr   )rS   reposr   r   r   rootrd   r   s           r   record_discovered_reposr   r  s     &CD Let$TECRWW%5%5d%;CtcJK	L 
4 	LL781 			 t9	 t9s   )(B$$B7c                    | j                  d      j                         }|D cg c]  }|d   |d   |d   d c}S c c}w )z;All cached discovered repo roots, most-recently-seen first.zKSELECT root, label, last_seen FROM discovered_repos ORDER BY last_seen DESCr   rd   	last_seen)r   rd   r   )rJ   r   )rS   r   r   s      r   list_discovered_reposr     sR    <<Uhj 	
  6QwZanM  s   =c                  t        |xs d      j                         syt        |      }d}|s|dz  }d}d}| j                  |      j	                         D ]  }|d   }||k(  sU|j                  |j                  d      t        j                  z         s$|j                  |j                  d      dz         sbt        |      |kD  sqt        |      }|d	   } |yt        | |      S )
zReturn the project owning ``path`` (longest-prefix folder match).

    A folder owns ``path`` when ``path`` equals the folder or is nested under
    it. The most specific (longest) folder wins, so nested projects resolve to
    the innermost one.
    r   NznSELECT pf.project_id AS pid, pf.path AS folder FROM project_folders pf JOIN projects p ON p.id = pf.project_idz WHERE p.archived = 0folderr4   /r   )r   r   r;   rJ   r   
startswithr9   r5   sepr   r   )	rS   r6   r   targetr   best_pidbest_lenr^   r   s	            r   project_for_pathr     s     tzr?  "T"F	J  &&"HH||C ))+ &XVv00u1E1NO!!&--"6"<=6{X%v;u:& tX&&r   z[^a-z0-9._-]+r   )titlec               ,   | j                   xs t        | j                        }| d| }|rgt        j	                  dt        |      j                         j                               j                  d      }|dd j                  d      }|r| d| }|S )zDeterministic branch name for a project-linked kanban task.

    Shape: ``<project-slug>/<task-id>`` (optionally ``-<title-slug>``). Stable
    and human-meaningful, replacing the random ``wt/<task-id>`` fallback.
    r   r   N(   )r&   r!   r   _BRANCH_SAFE_REr   r   r   r   )r   task_idr   r&   r   tslugs         r   branch_name_forr     s     <<18GLL1DV1WID##CU)9)9);)A)A)CDJJ3Ocr
  %V1UG$DKr   )rm   r   )r   r   rm   r   )r&   rc   rm   rc   )rm   r   )rm   r0   )r6   r   rm   r   r/   )rQ   Optional[Path]rm   sqlite3.Connection)rQ   r   )rS   r   rm   None)r^   zsqlite3.Rowrm   rt   )rS   r   r   r   rm   rz   )rS   r   r   rt   rm   rt   )rS   r   r   r   rm   r   )rS   r   r   r   r&   rc   r{   zOptional[Iterable[str]]rW   rc   rw   rc   rX   rc   rY   rc   rV   rc   rm   r   )rS   r   r   re   rm   zList[Project])rS   r   r   r   rm   Optional[Project])rS   r   r   r   r   rc   rw   rc   rX   rc   rY   rc   rV   rc   rm   re   )rS   r   r   r   r6   r   rd   rc   rf   re   rm   r   )rS   r   r   r   r6   r   rm   re   )rS   r   r   r   r6   r   rm   r   )rS   r   r   r   rm   re   )rS   r   r   rc   rm   r   )rS   r   rm   rc   )rS   r   r   z#Iterable[tuple[str, Optional[str]]]r   re   rm   r0   )rS   r   rm   z
List[dict])rS   r   r6   r   r   re   rm   r   )r   rt   r   r   r   r   rm   r   )B__doc__
__future__r   
contextlibr5   r   r+   rE   r1   dataclassesr   r   pathlibr   typingr   r   r	   hermes_cli.sqlite_utilr
   r]   r   hermes_constantsr   r   rL   compiler#   r!   r'   r-   r2   r;   setr<   rr   rF   contextmanagerrU   r\   rM   rb   rt   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   <module>r      s6  . #  	 	    (  + + ] ,-'
d 2::45 '   #u H $6  ( L I 
 
 
 
 
 
: "$ '+"&!% $@
@ @ 	@
 %@  @ @ @ @ @ 	@H ;@G
G37GG9
9*-99( !% $+
++ 	+
 + + + + 
+f  +
++ +
 + + 	+\<
*-58	&
  
)  	!
!.! 	!
 	!H" FK'
'$''>B''B "**-. EG r   