feat(sessions): record git workspace metadata

This commit is contained in:
Brooklyn Nicholson 2026-06-25 16:40:26 -05:00
parent c4ba4770eb
commit 4cdd1a3230
4 changed files with 218 additions and 5 deletions

View file

@ -99,6 +99,9 @@ export type {
ProfileSetupCommand,
ProfileSoul,
ProfilesResponse,
ProjectFolder,
ProjectInfo,
ProjectsPayload,
RpcEvent,
SessionCreateResponse,
SessionInfo,
@ -150,7 +153,9 @@ export async function listSessions(
order: 'created' | 'recent' = 'recent'
): Promise<PaginatedSessions> {
const result = await window.hermesDesktop.api<PaginatedSessions>({
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}&order=${order}`,
path:
`/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` +
`&archived=${archived}&order=${order}`,
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
})

View file

@ -323,6 +323,16 @@ export interface SessionCreateResponse {
export interface SessionInfo {
archived?: boolean
cwd?: null | string
/** Git branch checked out in {@link cwd} when the session started/resumed.
* The sidebar groups main-checkout sessions by this so feature-branch work
* doesn't collapse under a single directory-named "main" row. Null for
* non-git workspaces and sessions created before branch capture landed. */
git_branch?: null | string
/** Git repo root that owns {@link cwd} the authoritative project key,
* resolved server-side at cwd-set (and backfilled for history). The sidebar
* groups by this instead of probing git in the GUI. Null for non-git
* workspaces and not-yet-backfilled rows. */
git_repo_root?: null | string
ended_at: null | number
id: string
/** Original root id of a compression chain, when this entry is a projected
@ -335,6 +345,8 @@ export interface SessionInfo {
message_count: number
model: null | string
output_tokens: number
/** Parent conversation when this row is a /branch fork. */
parent_session_id?: null | string
preview: null | string
source: null | string
started_at: number
@ -533,6 +545,35 @@ export interface ProfileSetupCommand {
command: string
}
// ── Projects ───────────────────────────────────────────────────────────────
// A first-class, per-profile, human-named workspace spanning one or more
// folders. Mirrors hermes_cli/projects_db.Project.to_dict().
export interface ProjectFolder {
path: string
label: null | string
is_primary: boolean
added_at: number
}
export interface ProjectInfo {
id: string
slug: string
name: string
description: null | string
icon: null | string
color: null | string
board_slug: null | string
primary_path: null | string
archived: boolean
created_at: number
folders: ProjectFolder[]
}
export interface ProjectsPayload {
projects: ProjectInfo[]
active_id: null | string
}
export interface ProfileSoul {
content: string
exists: boolean

View file

@ -33,6 +33,11 @@ def _delegate_from_json(col: str = "model_config") -> str:
return f"json_extract(COALESCE({col}, '{{}}'), '$._delegate_from')"
def _cwd_prefix_clause(cwd_prefix: str) -> Tuple[str, List[str]]:
prefix = cwd_prefix.rstrip("/\\") or cwd_prefix
return "(s.cwd = ? OR s.cwd LIKE ? OR s.cwd LIKE ?)", [prefix, f"{prefix}/%", f"{prefix}\\%"]
# A child session counts as a /branch (kept visible, never cascade-deleted) if
# it carries the stable marker OR the legacy end_reason heuristic holds.
_BRANCH_CHILD_SQL = (
@ -539,6 +544,8 @@ CREATE TABLE IF NOT EXISTS sessions (
cache_write_tokens INTEGER DEFAULT 0,
reasoning_tokens INTEGER DEFAULT 0,
cwd TEXT,
git_branch TEXT,
git_repo_root TEXT,
billing_provider TEXT,
billing_base_url TEXT,
billing_mode TEXT,
@ -1407,13 +1414,62 @@ class SessionDB:
)
self._execute_write(_do)
def update_session_cwd(self, session_id: str, cwd: str) -> None:
"""Persist the session working directory when a frontend knows it."""
def update_session_cwd(
self, session_id: str, cwd: str, git_branch: str = None, git_repo_root: str = None
) -> None:
"""Persist the session working directory when a frontend knows it.
``git_branch`` records the git branch checked out in ``cwd`` at the time
the session started/resumed. The sidebar groups main-checkout sessions
by this so feature-branch work doesn't pile under a single "main" row
(the main checkout's *current* branch is transient and would
misattribute past sessions).
``git_repo_root`` records the git repo this cwd belongs to the
authoritative project key. Resolving it here, at the lowest level, means
every surface reads the same membership instead of re-probing git in the
GUI over a partial page. Each field is only written when non-empty so a
probe failure never clobbers a previously-captured value.
"""
if not session_id or not cwd:
return
branch = (git_branch or "").strip()
repo_root = (git_repo_root or "").strip()
sets = ["cwd = ?"]
params: List[Any] = [cwd]
if branch:
sets.append("git_branch = ?")
params.append(branch)
if repo_root:
sets.append("git_repo_root = ?")
params.append(repo_root)
params.append(session_id)
def _do(conn):
conn.execute("UPDATE sessions SET cwd = ? WHERE id = ?", (cwd, session_id))
conn.execute(f"UPDATE sessions SET {', '.join(sets)} WHERE id = ?", params)
self._execute_write(_do)
def backfill_repo_roots(self, cwd_to_root: Dict[str, str]) -> None:
"""Persist resolved git repo roots for cwds that don't have one yet.
Backfills history so projects light up for sessions created before the
column existed, without clobbering an already-recorded root. Only
non-empty roots are written (a non-git cwd stays NULL).
"""
pairs = [(root, cwd) for cwd, root in cwd_to_root.items() if root and cwd]
if not pairs:
return
def _do(conn):
for root, cwd in pairs:
conn.execute(
"UPDATE sessions SET git_repo_root = ? "
"WHERE cwd = ? AND COALESCE(git_repo_root, '') = ''",
(root, cwd),
)
self._execute_write(_do)
# ──────────────────────────────────────────────────────────────────────
@ -2102,10 +2158,37 @@ class SessionDB:
current = row["id"]
return current
def distinct_session_cwds(self, include_archived: bool = False) -> List[Dict[str, Any]]:
"""Distinct non-empty session cwds with usage stats, for repo discovery.
Aggregates across ALL session history (not a single page), so the desktop
can surface every git repo the user has worked in not just the repos
that happen to be in the currently-loaded recents. Children/branches
count: a worktree session is still a real workspace signal.
"""
where = "cwd IS NOT NULL AND TRIM(cwd) != ''"
if not include_archived:
where += " AND archived = 0"
with self._lock:
rows = self._conn.execute(
"SELECT cwd AS cwd, COUNT(*) AS sessions, "
"MAX(COALESCE(ended_at, started_at, 0)) AS last_active "
f"FROM sessions WHERE {where} GROUP BY cwd"
).fetchall()
return [
{
"cwd": r["cwd"],
"sessions": int(r["sessions"] or 0),
"last_active": float(r["last_active"] or 0),
}
for r in rows
]
def list_sessions_rich(
self,
source: str = None,
exclude_sources: List[str] = None,
cwd_prefix: str = None,
limit: int = 20,
offset: int = 0,
include_children: bool = False,
@ -2171,6 +2254,10 @@ class SessionDB:
placeholders = ",".join("?" for _ in exclude_sources)
where_clauses.append(f"s.source NOT IN ({placeholders})")
params.extend(exclude_sources)
if cwd_prefix:
clause, clause_params = _cwd_prefix_clause(cwd_prefix)
where_clauses.append(clause)
params.extend(clause_params)
if min_message_count > 0:
where_clauses.append("s.message_count >= ?")
params.append(min_message_count)
@ -2330,7 +2417,7 @@ class SessionDB:
for key in (
"id", "ended_at", "end_reason", "message_count",
"tool_call_count", "title", "last_active", "preview",
"model", "system_prompt", "cwd",
"model", "system_prompt", "cwd", "git_branch", "git_repo_root",
):
if key in tip_row:
merged[key] = tip_row[key]
@ -3874,6 +3961,7 @@ class SessionDB:
def session_count(
self,
source: str = None,
cwd_prefix: str = None,
min_message_count: int = 0,
include_archived: bool = False,
archived_only: bool = False,
@ -3910,6 +3998,10 @@ class SessionDB:
placeholders = ",".join("?" for _ in exclude_sources)
where_clauses.append(f"s.source NOT IN ({placeholders})")
params.extend(exclude_sources)
if cwd_prefix:
clause, clause_params = _cwd_prefix_clause(cwd_prefix)
where_clauses.append(clause)
params.extend(clause_params)
if min_message_count > 0:
where_clauses.append("s.message_count >= ?")
params.append(min_message_count)

View file

@ -96,6 +96,66 @@ class TestSessionLifecycle:
def test_get_nonexistent_session(self, db):
assert db.get_session("nonexistent") is None
def test_update_session_cwd_persists_git_branch(self, db):
db.create_session(session_id="s1", source="cli")
db.update_session_cwd("s1", "/work/repo", git_branch="pets-feature")
session = db.get_session("s1")
assert session["cwd"] == "/work/repo"
assert session["git_branch"] == "pets-feature"
def test_update_session_cwd_empty_branch_does_not_clobber(self, db):
"""A failed branch probe (empty string) must not wipe a branch we
already captured only the cwd updates."""
db.create_session(session_id="s1", source="cli")
db.update_session_cwd("s1", "/work/repo", git_branch="main")
db.update_session_cwd("s1", "/work/repo", git_branch="")
session = db.get_session("s1")
assert session["git_branch"] == "main"
def test_update_session_cwd_without_branch_arg(self, db):
"""Back-compat: callers that pass only (id, cwd) still work."""
db.create_session(session_id="s1", source="cli")
db.update_session_cwd("s1", "/work/repo")
session = db.get_session("s1")
assert session["cwd"] == "/work/repo"
assert session["git_branch"] is None
def test_update_session_cwd_persists_git_repo_root(self, db):
db.create_session(session_id="s1", source="cli")
db.update_session_cwd("s1", "/work/repo/src", git_repo_root="/work/repo")
assert db.get_session("s1")["git_repo_root"] == "/work/repo"
def test_update_session_cwd_empty_repo_root_does_not_clobber(self, db):
db.create_session(session_id="s1", source="cli")
db.update_session_cwd("s1", "/work/repo", git_repo_root="/work/repo")
db.update_session_cwd("s1", "/work/repo", git_repo_root="")
assert db.get_session("s1")["git_repo_root"] == "/work/repo"
def test_distinct_session_cwds_aggregates_history(self, db):
db.create_session("s1", "cli", cwd="/repo")
db.create_session("s2", "cli", cwd="/repo")
db.create_session("s3", "cli", cwd="/other")
db.create_session("s4", "cli") # no cwd — excluded
rows = {r["cwd"]: r["sessions"] for r in db.distinct_session_cwds()}
assert rows == {"/repo": 2, "/other": 1}
def test_backfill_repo_roots_fills_only_empty(self, db):
db.create_session("s1", "cli", cwd="/repo/a")
db.create_session("s2", "cli", cwd="/repo/b")
db.update_session_cwd("s2", "/repo/b", git_repo_root="/already")
db.backfill_repo_roots({"/repo/a": "/repo", "/repo/b": "/repo"})
assert db.get_session("s1")["git_repo_root"] == "/repo"
# Pre-existing root is preserved, not clobbered.
assert db.get_session("s2")["git_repo_root"] == "/already"
def test_end_session(self, db):
db.create_session(session_id="s1", source="cli")
db.end_session("s1", end_reason="user_exit")
@ -1526,6 +1586,13 @@ class TestCounts:
assert db.session_count(source="cli") == 2
assert db.session_count(source="telegram") == 1
def test_session_count_by_cwd_prefix(self, db):
db.create_session("s1", "cli", cwd="/repo")
db.create_session("s2", "cli", cwd="/repo-wt-feature")
db.create_session("s3", "cli", cwd="/repo/subdir")
assert db.session_count(cwd_prefix="/repo") == 2
def test_message_count_total(self, db):
assert db.message_count() == 0
db.create_session(session_id="s1", source="cli")
@ -3057,6 +3124,14 @@ class TestListSessionsRich:
assert len(sessions) == 1
assert sessions[0]["id"] == "s1"
def test_rich_list_cwd_prefix_filter(self, db):
db.create_session("s1", "cli", cwd="/repo")
db.create_session("s2", "cli", cwd="/repo/subdir")
db.create_session("s3", "cli", cwd="/repo-wt-feature")
sessions = db.list_sessions_rich(cwd_prefix="/repo")
assert [session["id"] for session in sessions] == ["s2", "s1"]
def test_preview_newlines_collapsed(self, db):
db.create_session("s1", "cli")
db.append_message("s1", "user", "Line one\nLine two\nLine three")