Compare commits

...

1 Commits

Author SHA1 Message Date
Ben
89d26bc430 feat(desktop): new session in a git worktree from the sidebar
Adds a per-workspace git-fork icon beside the existing "+" in the desktop
sidebar's workspace-group header. The "+" keeps current behaviour (new session
in the workspace cwd); the fork icon creates the session inside a fresh git
worktree of that repo — mirroring `hermes --worktree --tui`. The fork icon only
renders for workspaces that are real git repos (memoized per-path probe via a
new `git.is_repo` gateway method).

Backend:
- hermes_state.py: add worktree_path/worktree_branch/worktree_repo_root columns
  to the sessions table (auto-reconciled, no manual migration) + a
  set_session_worktree() setter.
- tui_gateway/server.py: `git.is_repo` RPC; _create_session_worktree() reuses
  cli._setup_worktree, repoints session cwd into the worktree, and persists the
  DB row EAGERLY (worktree sessions are explicit, so unlike blank drafts the row
  is created up front) stamped with the worktree mapping. Wired into
  session.create behind a `worktree` param; returns worktree info in the response.
- hermes_cli/web_server.py: on archive (PATCH /api/sessions/:id), remove the
  session's worktree via cli._cleanup_worktree, which keeps the existing
  unpushed-commits guard — a branch with commits not on any remote is preserved,
  not destroyed; the response reports worktree_preserved.

Frontend (apps/desktop):
- use-workspace-git.ts: memoized per-path git.is_repo probe (module-level
  nanostore cache, one probe per distinct path).
- sidebar: isGitRepo flag on workspace groups; fork button (Codicon
  `repo-forked`, MIT/CC-BY) gated on isGitRepo; onNewSessionWorktree threaded
  through, respecting the all-profiles-view gating.
-  one-shot flag consumed by the session-create path to add
  worktree:true; cleared on plain new-chat drafts.

Lifecycle: worktrees persist indefinitely and are reclaimed only on archive
(guarded). Tests cover the DB setter, git.is_repo (incl. fresh repo with no
commits), worktree create + eager-row persistence, and archive cleanup for both
the clean-removal and unpushed-preserve cases, against a real temp git repo.
2026-06-05 12:09:42 +10:00
9 changed files with 581 additions and 7 deletions

View File

@@ -75,6 +75,7 @@ import {
import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes'
import { SidebarPanelLabel } from '../../shell/sidebar-label'
import type { SidebarNavItem } from '../../types'
import { useWorkspaceGitRepos } from '../../session/hooks/use-workspace-git'
import { ProfileRail } from './profile-switcher'
import { SidebarSessionRow } from './session-row'
@@ -221,6 +222,8 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
onDeleteSession: (sessionId: string) => void
onArchiveSession: (sessionId: string) => void
onNewSessionInWorkspace: (path: null | string) => void
onNewSessionWorktree: (path: null | string) => void
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
}
export function ChatSidebar({
@@ -231,7 +234,9 @@ export function ChatSidebar({
onResumeSession,
onDeleteSession,
onArchiveSession,
onNewSessionInWorkspace
onNewSessionInWorkspace,
onNewSessionWorktree,
requestGateway
}: ChatSidebarProps) {
const sidebarOpen = useStore($sidebarOpen)
const panesFlipped = useStore($panesFlipped)
@@ -467,6 +472,19 @@ export function ChatSidebar({
sessionProfileTotals
])
// Probe each distinct workspace path for git-repo-ness (memoized, once per
// path) so the per-group "new session in a worktree" fork icon only appears
// for real repos.
const workspacePaths = useMemo(
() => agentGroups.map(g => g.path).filter((p): p is string => Boolean(p)),
[agentGroups]
)
const gitRepoPaths = useWorkspaceGitRepos(workspacePaths, requestGateway)
const agentGroupsWithRepo = useMemo(
() => agentGroups.map(g => ({ ...g, isGitRepo: g.path ? gitRepoPaths.has(g.path) : false })),
[agentGroups, gitRepoPaths]
)
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
// Pagination is scope-aware. In "All profiles" mode it tracks the global
@@ -693,7 +711,7 @@ export function ChatSidebar({
) : null
}
forceEmptyState={showSessionSkeletons}
groups={showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined}
groups={showAllProfiles ? profileGroups : agentsGrouped ? agentGroupsWithRepo : undefined}
headerAction={
// Always reserve the icon-xs (size-6) slot so the header keeps the
// same height whether or not the toggle renders — otherwise the
@@ -729,6 +747,7 @@ export function ChatSidebar({
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onNewSessionInWorkspace={showAllProfiles ? undefined : onNewSessionInWorkspace}
onNewSessionWorktree={showAllProfiles ? undefined : onNewSessionWorktree}
onReorder={showAllProfiles ? undefined : handleAgentDragEnd}
onResumeSession={onResumeSession}
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
@@ -823,6 +842,7 @@ interface SidebarSessionGroup {
mode?: 'profile' | 'workspace'
onLoadMore?: () => void
totalCount?: number
isGitRepo?: boolean
}
interface SidebarSessionsSectionProps {
@@ -837,6 +857,7 @@ interface SidebarSessionsSectionProps {
onArchiveSession: (sessionId: string) => void
onTogglePin: (sessionId: string) => void
onNewSessionInWorkspace?: (path: null | string) => void
onNewSessionWorktree?: (path: null | string) => void
pinned: boolean
rootClassName?: string
contentClassName?: string
@@ -863,6 +884,7 @@ function SidebarSessionsSection({
onArchiveSession,
onTogglePin,
onNewSessionInWorkspace,
onNewSessionWorktree,
pinned,
rootClassName,
contentClassName,
@@ -922,6 +944,7 @@ function SidebarSessionsSection({
group={group}
key={group.id}
onNewSession={onNewSessionInWorkspace}
onNewSessionWorktree={onNewSessionWorktree}
renderRows={renderSessionList}
/>
) : (
@@ -929,6 +952,7 @@ function SidebarSessionsSection({
group={group}
key={group.id}
onNewSession={onNewSessionInWorkspace}
onNewSessionWorktree={onNewSessionWorktree}
renderRows={renderSessionList}
/>
)
@@ -989,6 +1013,7 @@ interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> {
group: SidebarSessionGroup
renderRows: (sessions: SessionInfo[]) => React.ReactNode
onNewSession?: (path: null | string) => void
onNewSessionWorktree?: (path: null | string) => void
reorderable?: boolean
dragging?: boolean
dragHandleProps?: React.HTMLAttributes<HTMLElement>
@@ -998,6 +1023,7 @@ function SidebarWorkspaceGroup({
group,
renderRows,
onNewSession,
onNewSessionWorktree,
reorderable = false,
dragging = false,
dragHandleProps,
@@ -1066,6 +1092,17 @@ function SidebarWorkspaceGroup({
</button>
</Tip>
)}
{group.isGitRepo && onNewSessionWorktree && group.path && (
<button
aria-label={`New worktree session in ${group.label}`}
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
onClick={() => onNewSessionWorktree(group.path)}
title={`New session in a git worktree of ${group.label}`}
type="button"
>
<Codicon name="repo-forked" size="0.75rem" />
</button>
)}
{reorderable && (
<span
{...dragHandleProps}
@@ -1112,6 +1149,7 @@ interface SortableWorkspaceProps {
group: SidebarSessionGroup
renderRows: (sessions: SessionInfo[]) => React.ReactNode
onNewSession?: (path: null | string) => void
onNewSessionWorktree?: (path: null | string) => void
}
function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) {

View File

@@ -47,6 +47,7 @@ import {
setCurrentModel,
setCurrentProvider,
setMessages,
setPendingWorktree,
setSessionProfileTotals,
setSessions,
setSessionsLoading,
@@ -548,6 +549,23 @@ export function DesktopController() {
[requestGateway, startFreshSessionDraft]
)
const startSessionInWorktree = useCallback(
(path: null | string) => {
const target = path?.trim()
if (!target) {
return
}
// Same as a workspace new-session, but arm the one-shot worktree flag so
// the backend creates the session inside a fresh git worktree of this
// repo. startFreshSessionDraft() clears the flag, so arm it afterwards.
startSessionInWorkspace(target)
setPendingWorktree(true)
},
[startSessionInWorkspace]
)
const handleSkinCommand = useSkinCommand()
const { cancelRun, editMessage, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio } =
@@ -628,7 +646,9 @@ export function DesktopController() {
onLoadMoreSessions={loadMoreSessions}
onNavigate={selectSidebarItem}
onNewSessionInWorkspace={startSessionInWorkspace}
onNewSessionWorktree={startSessionInWorktree}
onResumeSession={sessionId => navigate(sessionRoute(sessionId))}
requestGateway={requestGateway}
/>
)

View File

@@ -16,6 +16,7 @@ import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalize
import {
$currentCwd,
$messages,
$pendingWorktree,
$sessions,
$yoloActive,
getRememberedWorkspaceCwd,
@@ -35,6 +36,7 @@ import {
setFreshDraftReady,
setIntroSeed,
setMessages,
setPendingWorktree,
setSelectedStoredSessionId,
setSessions,
setSessionStartedAt,
@@ -311,6 +313,8 @@ export function useSessionActions({
// New chats inherit the current workspace.
setCurrentCwd(getRememberedWorkspaceCwd())
setCurrentBranch('')
// A plain new-chat draft is never a worktree session; clear any stale arm.
setPendingWorktree(false)
clearComposerDraft()
clearComposerAttachments()
setFreshDraftReady(true)
@@ -331,7 +335,17 @@ export function useSessionActions({
// so single-profile users are unaffected).
await ensureGatewayProfile($newChatProfile.get())
const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd()
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96, ...(cwd && { cwd }) })
// The fork icon arms a one-shot worktree request; consume + reset it so
// a later plain new-chat doesn't accidentally inherit it.
const worktree = $pendingWorktree.get()
if (worktree) {
setPendingWorktree(false)
}
const created = await requestGateway<SessionCreateResponse>('session.create', {
cols: 96,
...(cwd && { cwd }),
...(worktree && cwd ? { worktree: true } : {})
})
const stored = created.stored_session_id ?? null
if (

View File

@@ -0,0 +1,65 @@
import { useEffect } from 'react'
import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
/**
* Per-workspace git-repo detection for the sidebar.
*
* The "new session in a worktree" fork icon must only appear for workspace
* groups whose path is a real git repository. We probe each distinct path once
* via the `git.is_repo` gateway method and memoize the answer for the lifetime
* of the renderer — a workspace doesn't stop being a repo while the app is open,
* and re-probing on every sidebar render would be wasteful.
*
* Results live in a module-level nanostore so every sidebar instance shares one
* cache and re-renders when a probe resolves.
*/
// path -> isRepo. Absence means "not yet probed".
const $repoByPath = atom<Record<string, boolean>>({})
// Paths with an in-flight or completed probe, so we never probe the same path
// twice (even before the first result lands).
const probed = new Set<string>()
type RequestGateway = <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
async function probePath(path: string, requestGateway: RequestGateway): Promise<void> {
try {
const res = await requestGateway<{ is_repo?: boolean }>('git.is_repo', { cwd: path })
$repoByPath.set({ ...$repoByPath.get(), [path]: Boolean(res?.is_repo) })
} catch {
// Treat a failed probe as "not a repo" — the icon simply won't appear, and
// the backend would fall back gracefully anyway if it somehow got asked.
$repoByPath.set({ ...$repoByPath.get(), [path]: false })
}
}
/**
* Probe every supplied workspace path for git-repo-ness (once each) and return
* a `Set` of the paths that are repos. Re-renders when probes resolve.
*
* @param paths Distinct, non-null workspace paths to probe.
* @param requestGateway Gateway RPC caller.
*/
export function useWorkspaceGitRepos(paths: string[], requestGateway: RequestGateway): Set<string> {
const repoByPath = useStore($repoByPath)
useEffect(() => {
for (const path of paths) {
if (!path || probed.has(path)) {
continue
}
probed.add(path)
void probePath(path, requestGateway)
}
}, [paths, requestGateway])
const repos = new Set<string>()
for (const [path, isRepo] of Object.entries(repoByPath)) {
if (isRepo) {
repos.add(path)
}
}
return repos
}

View File

@@ -100,6 +100,10 @@ export const $currentFastMode = atom(false)
export const $yoloActive = atom(false)
export const $currentCwd = atom(getRememberedWorkspaceCwd())
export const $currentBranch = atom('')
// When true, the next backend session is created inside a fresh git worktree of
// $currentCwd (set by the sidebar's "new session in a worktree" fork icon).
// Consumed and reset by the create-session path.
export const $pendingWorktree = atom(false)
export const $currentUsage = atom<UsageStats>({
calls: 0,
input: 0,
@@ -144,6 +148,7 @@ export const setCurrentCwd = (next: Updater<string>) => {
}
export const setCurrentBranch = (next: Updater<string>) => updateAtom($currentBranch, next)
export const setPendingWorktree = (next: Updater<boolean>) => updateAtom($pendingWorktree, next)
export const setCurrentUsage = (next: Updater<UsageStats>) => updateAtom($currentUsage, next)
export const setSessionStartedAt = (next: Updater<number | null>) => updateAtom($sessionStartedAt, next)
export const setTurnStartedAt = (next: Updater<number | null>) => updateAtom($turnStartedAt, next)

View File

@@ -5265,6 +5265,58 @@ class SessionRename(BaseModel):
profile: Optional[str] = None
def _cleanup_session_worktree(db, sid: str) -> Optional[bool]:
"""Remove the git worktree owned by a session, honoring the unpushed guard.
Returns:
* ``None`` — the session had no worktree (nothing to do).
* ``False`` — the worktree was removed.
* ``True`` — the worktree was *preserved* because its branch has commits
not reachable from any remote (the unpushed-commits guard in
``_cleanup_worktree`` declined to delete it).
On removal, the worktree mapping is cleared from the session row so a repeat
archive is a no-op. Best-effort: never raises into the request handler.
"""
try:
row = db.get_session(sid)
except Exception:
return None
if not row:
return None
wt_path = row.get("worktree_path")
if not wt_path:
return None
info = {
"path": wt_path,
"branch": row.get("worktree_branch"),
"repo_root": row.get("worktree_repo_root"),
}
try:
from cli import _cleanup_worktree
except Exception:
_log.debug("worktree cleanup helper unavailable", exc_info=True)
return None
try:
_cleanup_worktree(info)
except Exception:
_log.debug("worktree cleanup failed for session %s", sid, exc_info=True)
return None
# _cleanup_worktree preserves worktrees with unpushed commits. Probe the
# filesystem to learn what actually happened: gone → removed (clear the
# mapping); still present → preserved (keep the mapping so we can retry).
still_present = os.path.isdir(wt_path)
if not still_present:
try:
db.set_session_worktree(sid, None)
except Exception:
_log.debug("failed to clear worktree mapping for %s", sid, exc_info=True)
return still_present
@app.patch("/api/sessions/{session_id}")
async def rename_session_endpoint(session_id: str, body: SessionRename):
"""Update a session: rename (or clear its title) and/or archive it.
@@ -5294,6 +5346,14 @@ async def rename_session_endpoint(session_id: str, body: SessionRename):
result = {"ok": True, "title": db.get_session_title(sid) or ""}
if body.archived is not None:
result["archived"] = bool(body.archived)
# Archiving a session reclaims its git worktree (created via the
# desktop "new session in a worktree" fork icon). _cleanup_worktree
# keeps the unpushed-commits guard, so a worktree whose branch has
# commits not on any remote is preserved rather than destroyed.
if body.archived:
preserved = _cleanup_session_worktree(db, sid)
if preserved is not None:
result["worktree_preserved"] = preserved
return result
finally:
db.close()

View File

@@ -265,6 +265,9 @@ CREATE TABLE IF NOT EXISTS sessions (
handoff_error TEXT,
rewind_count INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
worktree_path TEXT,
worktree_branch TEXT,
worktree_repo_root TEXT,
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
);
@@ -1492,6 +1495,33 @@ class SessionDB:
rowcount = self._execute_write(_do)
return rowcount > 0
def set_session_worktree(
self,
session_id: str,
worktree_path: Optional[str],
worktree_branch: Optional[str] = None,
worktree_repo_root: Optional[str] = None,
) -> bool:
"""Record (or clear) the git worktree owned by a session.
Desktop "new session in a worktree" creates a throwaway git worktree and
runs the session inside it. We stamp the worktree path/branch/repo-root
on the row so the archive handler can find and remove the worktree later
— even across a backend restart, when the in-memory session map is gone.
Pass ``worktree_path=None`` to clear the mapping (e.g. after cleanup).
Returns True when a row was updated.
"""
def _do(conn):
cursor = conn.execute(
"UPDATE sessions SET worktree_path = ?, worktree_branch = ?, "
"worktree_repo_root = ? WHERE id = ?",
(worktree_path, worktree_branch, worktree_repo_root, session_id),
)
return cursor.rowcount
rowcount = self._execute_write(_do)
return rowcount > 0
def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]:
"""Look up a session by exact title. Returns session dict or None."""
with self._lock:

View File

@@ -0,0 +1,222 @@
"""Tests for the desktop "new session in a worktree" feature.
Covers the three backend pieces:
* ``SessionDB.set_session_worktree`` persistence + read-back (hermes_state).
* ``tui_gateway.server._git_is_repo`` / ``_create_session_worktree`` — worktree
creation wired into ``session.create``, with eager DB-row persistence.
* ``hermes_cli.web_server._cleanup_session_worktree`` — archive-time cleanup
that honours the unpushed-commits guard.
The worktree machinery itself lives in ``cli.py`` and is exercised here against
a real temporary git repo (no mocks) so the create/cleanup round-trip is proven
end-to-end, not just in unit isolation.
"""
import os
import subprocess
import pytest
from hermes_state import SessionDB
def _init_git_repo(path: str, *, with_commit: bool = True, with_remote: bool = False) -> None:
"""Initialise a real git repo at *path* for worktree tests."""
subprocess.run(["git", "init", "-q", path], check=True)
subprocess.run(["git", "-C", path, "config", "user.email", "t@example.com"], check=True)
subprocess.run(["git", "-C", path, "config", "user.name", "Tester"], check=True)
if with_commit:
readme = os.path.join(path, "README.md")
with open(readme, "w", encoding="utf-8") as f:
f.write("hello\n")
subprocess.run(["git", "-C", path, "add", "."], check=True)
subprocess.run(["git", "-C", path, "commit", "-qm", "init"], check=True)
if with_remote:
# A bare "remote" so unpushed-commit detection has a baseline to compare
# against (without a remote, _worktree_has_unpushed_commits treats the
# worktree as having nothing unpushed).
remote = path + "-remote.git"
subprocess.run(["git", "init", "-q", "--bare", remote], check=True)
subprocess.run(["git", "-C", path, "remote", "add", "origin", remote], check=True)
subprocess.run(["git", "-C", path, "push", "-q", "origin", "HEAD"], check=True)
class TestSetSessionWorktree:
def test_persists_and_reads_back(self, tmp_path):
db = SessionDB(db_path=tmp_path / "state.db")
try:
db.create_session("s1", source="tui", cwd="/repo/.worktrees/hermes-abc")
assert db.set_session_worktree(
"s1", "/repo/.worktrees/hermes-abc", "hermes/hermes-abc", "/repo"
)
row = db.get_session("s1")
assert row["worktree_path"] == "/repo/.worktrees/hermes-abc"
assert row["worktree_branch"] == "hermes/hermes-abc"
assert row["worktree_repo_root"] == "/repo"
finally:
db.close()
def test_clear_mapping(self, tmp_path):
db = SessionDB(db_path=tmp_path / "state.db")
try:
db.create_session("s1", source="tui")
db.set_session_worktree("s1", "/wt", "hermes/x", "/repo")
assert db.set_session_worktree("s1", None)
row = db.get_session("s1")
assert row["worktree_path"] is None
assert row["worktree_branch"] is None
assert row["worktree_repo_root"] is None
finally:
db.close()
def test_missing_row_returns_false(self, tmp_path):
db = SessionDB(db_path=tmp_path / "state.db")
try:
assert db.set_session_worktree("nope", "/wt") is False
finally:
db.close()
class TestGitIsRepo:
def test_true_for_repo(self, tmp_path):
from tui_gateway import server
repo = str(tmp_path / "repo")
_init_git_repo(repo)
assert server._git_is_repo(repo) is True
def test_true_for_repo_without_commits(self, tmp_path):
# A freshly `git init`-ed repo (no commits, no current branch) is still a
# repo — this is the case a branch-name probe would get wrong.
from tui_gateway import server
repo = str(tmp_path / "fresh")
_init_git_repo(repo, with_commit=False)
assert server._git_is_repo(repo) is True
def test_false_for_plain_dir(self, tmp_path):
from tui_gateway import server
plain = str(tmp_path / "plain")
os.makedirs(plain)
assert server._git_is_repo(plain) is False
def test_false_for_empty_or_missing(self, tmp_path):
from tui_gateway import server
assert server._git_is_repo("") is False
assert server._git_is_repo(str(tmp_path / "does-not-exist")) is False
class TestCreateSessionWorktree:
def test_creates_worktree_and_persists_row(self, tmp_path, monkeypatch):
from tui_gateway import server
repo = str(tmp_path / "repo")
_init_git_repo(repo)
db = SessionDB(db_path=tmp_path / "state.db")
monkeypatch.setattr(server, "_db", db)
try:
session = {"session_key": "KEY1", "cwd": repo, "explicit_cwd": True}
info = server._create_session_worktree(session, repo)
assert info is not None
# cwd repointed into the worktree, on a hermes/ branch.
assert ".worktrees" in info["path"]
assert info["branch"].startswith("hermes/")
assert os.path.isdir(info["path"])
assert session["cwd"] == info["path"]
assert session["worktree"] == info
# Row persisted EAGERLY (not lazily) with the worktree mapping.
row = db.get_session("KEY1")
assert row is not None
assert row["cwd"] == info["path"]
assert row["worktree_path"] == info["path"]
assert row["worktree_branch"] == info["branch"]
finally:
db.close()
def test_non_repo_returns_none(self, tmp_path, monkeypatch):
from tui_gateway import server
plain = str(tmp_path / "plain")
os.makedirs(plain)
db = SessionDB(db_path=tmp_path / "state.db")
monkeypatch.setattr(server, "_db", db)
try:
session = {"session_key": "KEY2", "cwd": plain, "explicit_cwd": True}
assert server._create_session_worktree(session, plain) is None
# cwd unchanged, no row stamped with a worktree.
assert session["cwd"] == plain
assert "worktree" not in session
finally:
db.close()
class TestCleanupSessionWorktree:
def test_removes_clean_worktree_and_clears_mapping(self, tmp_path, monkeypatch):
from tui_gateway import server
from hermes_cli import web_server
repo = str(tmp_path / "repo")
_init_git_repo(repo, with_remote=True) # remote => unpushed detection active
db = SessionDB(db_path=tmp_path / "state.db")
monkeypatch.setattr(server, "_db", db)
try:
session = {"session_key": "KEY3", "cwd": repo, "explicit_cwd": True}
info = server._create_session_worktree(session, repo)
assert info and os.path.isdir(info["path"])
# No commits made in the worktree => nothing unpushed => removable.
preserved = web_server._cleanup_session_worktree(db, "KEY3")
assert preserved is False
assert not os.path.isdir(info["path"])
# Mapping cleared so a repeat archive is a no-op.
row = db.get_session("KEY3")
assert row["worktree_path"] is None
finally:
db.close()
def test_preserves_worktree_with_unpushed_commits(self, tmp_path, monkeypatch):
from tui_gateway import server
from hermes_cli import web_server
repo = str(tmp_path / "repo")
_init_git_repo(repo, with_remote=True)
db = SessionDB(db_path=tmp_path / "state.db")
monkeypatch.setattr(server, "_db", db)
try:
session = {"session_key": "KEY4", "cwd": repo, "explicit_cwd": True}
info = server._create_session_worktree(session, repo)
assert info and os.path.isdir(info["path"])
# Make an unpushed commit on the worktree branch — the guard must
# then refuse to delete it.
wt = info["path"]
with open(os.path.join(wt, "work.txt"), "w", encoding="utf-8") as f:
f.write("unpushed work\n")
subprocess.run(["git", "-C", wt, "add", "."], check=True)
subprocess.run(["git", "-C", wt, "commit", "-qm", "wip"], check=True)
preserved = web_server._cleanup_session_worktree(db, "KEY4")
assert preserved is True
assert os.path.isdir(wt) # still there — guard kept it
# Mapping retained so a later (post-push) archive can retry.
row = db.get_session("KEY4")
assert row["worktree_path"] == wt
finally:
db.close()
def test_no_worktree_returns_none(self, tmp_path):
from hermes_cli import web_server
db = SessionDB(db_path=tmp_path / "state.db")
try:
db.create_session("plain", source="tui")
assert web_server._cleanup_session_worktree(db, "plain") is None
finally:
db.close()

View File

@@ -684,6 +684,29 @@ def _git_branch_for_cwd(cwd: str) -> str:
return ""
def _git_is_repo(cwd: str) -> bool:
"""Return True when *cwd* is inside a git working tree.
Used to gate the desktop sidebar's "new session in a worktree" affordance:
the fork icon only appears for workspaces that are real git repos. Unlike a
branch-name probe, this is unambiguous for a freshly-`git init`-ed repo with
no commits yet (which has no current branch).
"""
if not cwd:
return False
try:
result = subprocess.run(
["git", "-C", cwd, "rev-parse", "--is-inside-work-tree"],
capture_output=True,
text=True,
timeout=1.5,
check=False,
)
return result.returncode == 0 and result.stdout.strip() == "true"
except Exception:
return False
def _session_cwd(session: dict | None) -> str:
if session and session.get("cwd"):
return str(session["cwd"])
@@ -735,6 +758,81 @@ def _ensure_session_db_row(session: dict) -> None:
logger.debug("failed to persist desktop session row", exc_info=True)
def _create_session_worktree(session: dict, repo_cwd: str) -> dict | None:
"""Create a git worktree for a desktop "new session in a worktree" request.
Reuses the same worktree machinery as ``hermes --worktree --tui`` (defined in
``cli.py``). On success:
* a fresh worktree is created under ``<repo>/.worktrees/hermes-<id>`` on a
``hermes/hermes-<id>`` branch,
* ``session["cwd"]`` is repointed at the worktree (so the agent + all its
tools run inside it),
* ``session["worktree"]`` holds the ``{path, branch, repo_root}`` metadata,
* the session's DB row is persisted **eagerly** (unlike normal drafts) and
stamped with the worktree mapping, so the archive handler can find and
remove the worktree later — even after a backend restart.
Returns the worktree info dict, or ``None`` if *repo_cwd* is not a git repo
or worktree creation failed (caller falls back to the plain workspace cwd).
"""
if not repo_cwd or not _git_is_repo(repo_cwd):
return None
try:
from cli import _setup_worktree
except Exception:
logger.warning("worktree helpers unavailable; falling back to plain cwd", exc_info=True)
return None
try:
# Resolve the repo root for the requested workspace explicitly (rather
# than relying on the gateway's process CWD) so the worktree is created
# against the folder the user actually picked.
result = subprocess.run(
["git", "-C", repo_cwd, "rev-parse", "--show-toplevel"],
capture_output=True, text=True, timeout=5, check=False,
)
repo_root = result.stdout.strip() if result.returncode == 0 else None
if not repo_root:
return None
wt_info = _setup_worktree(repo_root=repo_root)
except Exception:
logger.warning("failed to create session worktree", exc_info=True)
return None
if not wt_info or not wt_info.get("path"):
return None
session["cwd"] = wt_info["path"]
session["explicit_cwd"] = True
session["worktree"] = wt_info
_register_session_cwd(session)
# A worktree is an explicit, heavy action (unlike a blank draft), so persist
# the row eagerly and stamp the worktree mapping. create_session is
# INSERT-OR-IGNORE, so the later lazy create + the AIAgent's own insert
# remain no-ops.
key = session.get("session_key")
db = _get_db()
if key and db is not None:
try:
db.create_session(
key,
source="tui",
model=_resolve_model(),
cwd=wt_info["path"],
)
db.set_session_worktree(
key,
wt_info["path"],
wt_info.get("branch"),
wt_info.get("repo_root"),
)
except Exception:
logger.debug("failed to persist worktree session row", exc_info=True)
return wt_info
def _set_session_cwd(session: dict, cwd: str) -> str:
resolved = os.path.abspath(os.path.expanduser(str(cwd)))
if not os.path.isdir(resolved):
@@ -2796,6 +2894,19 @@ def _inflight_snapshot(session: dict) -> dict | None:
# ── Methods: session ─────────────────────────────────────────────────
@method("git.is_repo")
def _(rid, params: dict) -> dict:
"""Report whether a directory is a git repository.
Desktop sidebar uses this to gate the per-workspace "new session in a
worktree" fork icon — it only renders for workspaces that are real repos.
Accepts an explicit ``cwd`` (a workspace path); falls back to the session /
launch directory resolution used elsewhere.
"""
cwd = _completion_cwd(params)
return _ok(rid, {"cwd": cwd, "is_repo": _git_is_repo(cwd)})
@method("session.create")
def _(rid, params: dict) -> dict:
sid = uuid.uuid4().hex[:8]
@@ -2844,10 +2955,17 @@ def _(rid, params: dict) -> dict:
"transport": current_transport() or _stdio_transport,
}
_register_session_cwd(_sessions[sid])
# NOTE: we intentionally do NOT persist a DB row here. Every TUI/desktop
# launch (and every "New agent" / draft) opens a session here just to paint
# the composer, so eagerly creating a row left an "Untitled" empty session
# behind for every launch the user never typed into. The row is now created
# "New session in a worktree" (desktop sidebar fork icon): when the client
# asks for a worktree and the chosen workspace is a git repo, create an
# isolated worktree and run the session inside it. On failure we fall back to
# the plain workspace cwd (the helper returns None and logs).
worktree_info = None
if bool(params.get("worktree")) and explicit_cwd:
worktree_info = _create_session_worktree(
_sessions[sid], os.path.abspath(os.path.expanduser(raw_cwd))
)
# NOTE: for non-worktree sessions we intentionally do NOT persist a DB row
# lazily on the first prompt (see _ensure_session_db_row + prompt.submit),
# and the AIAgent's own INSERT-OR-IGNORE persists it on the first turn too.
@@ -2878,6 +2996,8 @@ def _(rid, params: dict) -> dict:
"cwd": _sessions[sid]["cwd"],
"branch": _git_branch_for_cwd(_sessions[sid]["cwd"]),
"lazy": True,
"worktree": bool(worktree_info),
"worktree_path": worktree_info["path"] if worktree_info else None,
"desktop_contract": DESKTOP_BACKEND_CONTRACT,
"profile_name": _current_profile_name(),
},