mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 12:18:44 +08:00
Compare commits
1 Commits
bb/docs-re
...
feat/deskt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89d26bc430 |
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
65
apps/desktop/src/app/session/hooks/use-workspace-git.ts
Normal file
65
apps/desktop/src/app/session/hooks/use-workspace-git.ts
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
222
tests/test_desktop_worktree_sessions.py
Normal file
222
tests/test_desktop_worktree_sessions.py
Normal 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()
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user