Add worktree-backed session creation
This commit is contained in:
1 parent
9c9d65a41a
commit
186453ea0e
12 files changed
+433
-7
No files matched your search
@@ -2,6 +2,8 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Added worktree-backed conversation creation for development sessions: the workspace menu can now create a new conversation in a Hermes Agent git worktree, persist the worktree path/branch/repo metadata on the session, and show a sidebar worktree indicator without adding automatic cleanup yet (#1955).
|
||||
|
||||
## [v0.51.42] — 2026-05-11 — Release R (5-PR contributor batch — session recovery state.db reconciliation + RFC convention + MEDIA_ALLOWED_ROOTS + Slack cron delivery)
|
||||
|
||||
### Added
|
||||
|
||||
+30
-3
@@ -335,6 +335,10 @@ class Session:
|
||||
gateway_routing=None, gateway_routing_history=None,
|
||||
llm_title_generated: bool=False,
|
||||
parent_session_id: str=None,
|
||||
worktree_path=None,
|
||||
worktree_branch=None,
|
||||
worktree_repo_root=None,
|
||||
worktree_created_at=None,
|
||||
enabled_toolsets=None,
|
||||
composer_draft=None,
|
||||
**kwargs):
|
||||
@@ -370,6 +374,10 @@ class Session:
|
||||
self.gateway_routing_history = gateway_routing_history if isinstance(gateway_routing_history, list) else []
|
||||
self.llm_title_generated = bool(llm_title_generated)
|
||||
self.parent_session_id = parent_session_id
|
||||
self.worktree_path = str(Path(worktree_path).expanduser().resolve()) if worktree_path else None
|
||||
self.worktree_branch = str(worktree_branch) if worktree_branch else None
|
||||
self.worktree_repo_root = str(Path(worktree_repo_root).expanduser().resolve()) if worktree_repo_root else None
|
||||
self.worktree_created_at = worktree_created_at
|
||||
self.is_cli_session = bool(kwargs.get('is_cli_session', False))
|
||||
self.source_tag = kwargs.get('source_tag')
|
||||
self.raw_source = kwargs.get('raw_source')
|
||||
@@ -417,6 +425,7 @@ class Session:
|
||||
'context_length', 'threshold_tokens', 'last_prompt_tokens',
|
||||
'gateway_routing', 'gateway_routing_history', 'llm_title_generated',
|
||||
'parent_session_id',
|
||||
'worktree_path', 'worktree_branch', 'worktree_repo_root', 'worktree_created_at',
|
||||
'is_cli_session', 'source_tag', 'raw_source', 'session_source', 'source_label',
|
||||
'enabled_toolsets', 'composer_draft',
|
||||
]
|
||||
@@ -584,6 +593,12 @@ class Session:
|
||||
# Only emit 'parent_session_id' when set (the /branch fork link, #1342).
|
||||
# Sessions without a fork must not leak None — see test_session_lineage_metadata_api.
|
||||
**({'parent_session_id': self.parent_session_id} if self.parent_session_id else {}),
|
||||
**({
|
||||
'worktree_path': self.worktree_path,
|
||||
'worktree_branch': self.worktree_branch,
|
||||
'worktree_repo_root': self.worktree_repo_root,
|
||||
'worktree_created_at': self.worktree_created_at,
|
||||
} if self.worktree_path else {}),
|
||||
'user_message_count': sum(
|
||||
1 for message in self.messages if _message_role(message) == 'user'
|
||||
) if isinstance(self.messages, list) else 0,
|
||||
@@ -896,7 +911,7 @@ def get_session(sid, metadata_only=False):
|
||||
return s
|
||||
raise KeyError(sid)
|
||||
|
||||
def new_session(workspace=None, model=None, profile=None, model_provider=None, project_id=None):
|
||||
def new_session(workspace=None, model=None, profile=None, model_provider=None, project_id=None, worktree_info=None):
|
||||
"""Create a new in-memory session.
|
||||
|
||||
The session lives in the SESSIONS dict only — no disk write happens until
|
||||
@@ -911,7 +926,9 @@ def new_session(workspace=None, model=None, profile=None, model_provider=None, p
|
||||
|
||||
Crash-safety: if the process exits between session creation and first
|
||||
message, the session is lost. Since it had no messages, there is
|
||||
nothing to lose.
|
||||
nothing to lose. Worktree-backed sessions are the exception: they are
|
||||
saved immediately because creating the session also creates real
|
||||
filesystem state that must remain discoverable after restart.
|
||||
|
||||
*profile* — when supplied by the caller (e.g. from the request body sent
|
||||
by the active browser tab), it is used directly so that concurrent clients
|
||||
@@ -927,18 +944,26 @@ def new_session(workspace=None, model=None, profile=None, model_provider=None, p
|
||||
except ImportError:
|
||||
profile = None
|
||||
effective_model = model or get_effective_default_model()
|
||||
wt = worktree_info if isinstance(worktree_info, dict) else None
|
||||
workspace_path = (wt.get('path') if wt and wt.get('path') else workspace) if wt else workspace
|
||||
s = Session(
|
||||
workspace=workspace or get_last_workspace(),
|
||||
workspace=workspace_path or get_last_workspace(),
|
||||
model=effective_model,
|
||||
model_provider=model_provider,
|
||||
profile=profile,
|
||||
project_id=project_id,
|
||||
worktree_path=wt.get('path') if wt else None,
|
||||
worktree_branch=wt.get('branch') if wt else None,
|
||||
worktree_repo_root=wt.get('repo_root') if wt else None,
|
||||
worktree_created_at=wt.get('created_at') if wt else None,
|
||||
)
|
||||
with LOCK:
|
||||
SESSIONS[s.session_id] = s
|
||||
SESSIONS.move_to_end(s.session_id)
|
||||
while len(SESSIONS) > SESSIONS_MAX:
|
||||
SESSIONS.popitem(last=False)
|
||||
if wt:
|
||||
s.save()
|
||||
return s
|
||||
|
||||
def _hide_from_default_sidebar(session: dict) -> bool:
|
||||
@@ -1042,6 +1067,7 @@ def all_sessions(diag=None):
|
||||
and s.get('message_count', 0) == 0
|
||||
and not s.get('active_stream_id')
|
||||
and not s.get('has_pending_user_message')
|
||||
and not s.get('worktree_path')
|
||||
)]
|
||||
result = [s for s in result if not _hide_from_default_sidebar(s)]
|
||||
# Backfill: sessions created before Sprint 22 have no profile tag.
|
||||
@@ -1077,6 +1103,7 @@ def all_sessions(diag=None):
|
||||
and len(s.messages) == 0
|
||||
and not s.active_stream_id
|
||||
and not s.pending_user_message
|
||||
and not getattr(s, 'worktree_path', None)
|
||||
)]
|
||||
result = [s for s in result if not _hide_from_default_sidebar(s)]
|
||||
for s in result:
|
||||
|
||||
@@ -3849,6 +3849,22 @@ def handle_post(handler, parsed) -> bool:
|
||||
workspace = str(resolve_trusted_workspace(body.get("workspace"))) if body.get("workspace") else None
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
worktree_info = None
|
||||
worktree_requested = (
|
||||
body.get("worktree") is True
|
||||
or str(body.get("worktree")).strip().lower() in {"1", "true", "yes", "on"}
|
||||
)
|
||||
if worktree_requested:
|
||||
try:
|
||||
from api.worktrees import create_worktree_for_workspace
|
||||
base_workspace = workspace or str(resolve_trusted_workspace(get_last_workspace()))
|
||||
worktree_info = create_worktree_for_workspace(base_workspace)
|
||||
workspace = worktree_info["path"]
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e), status=400)
|
||||
except Exception as e:
|
||||
logger.exception("failed to create worktree-backed session")
|
||||
return bad(handler, f"Failed to create worktree: {e}", status=500)
|
||||
model, model_provider = _session_model_state_from_request(
|
||||
body.get("model"),
|
||||
body.get("model_provider"),
|
||||
@@ -3861,6 +3877,7 @@ def handle_post(handler, parsed) -> bool:
|
||||
model_provider=model_provider,
|
||||
profile=body.get("profile") or None,
|
||||
project_id=body.get("project_id") or None,
|
||||
worktree_info=worktree_info,
|
||||
)
|
||||
return j(handler, {"session": s.compact() | {"messages": s.messages}})
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Helpers for WebUI-managed Hermes Agent git worktrees."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def find_git_repo_root(workspace: str | Path) -> Path:
|
||||
"""Return the enclosing git repo root for *workspace*.
|
||||
|
||||
Use git itself instead of checking ``workspace/.git`` so nested workspaces
|
||||
and linked git worktrees are both handled correctly.
|
||||
"""
|
||||
ws = Path(workspace).expanduser().resolve()
|
||||
if not ws.is_dir():
|
||||
raise ValueError("Workspace path does not exist or is not a directory")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
cwd=ws,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise ValueError("Workspace is not inside a git repository") from exc
|
||||
if result.returncode != 0:
|
||||
raise ValueError("Workspace is not inside a git repository")
|
||||
root = result.stdout.strip()
|
||||
if not root:
|
||||
raise ValueError("Workspace is not inside a git repository")
|
||||
return Path(root).expanduser().resolve()
|
||||
|
||||
|
||||
def _setup_agent_worktree(repo_root: str) -> dict:
|
||||
try:
|
||||
import api.config # noqa: F401 # ensure Hermes Agent dir is on sys.path
|
||||
from cli import _setup_worktree
|
||||
except Exception as exc:
|
||||
raise RuntimeError("Hermes Agent worktree helper is unavailable") from exc
|
||||
output = StringIO()
|
||||
with redirect_stdout(output), redirect_stderr(output):
|
||||
info = _setup_worktree(repo_root)
|
||||
emitted = output.getvalue().strip()
|
||||
if emitted:
|
||||
logger.debug("Hermes Agent worktree helper output: %s", emitted)
|
||||
if not info:
|
||||
raise RuntimeError("Hermes Agent failed to create a git worktree")
|
||||
return info
|
||||
|
||||
|
||||
def create_worktree_for_workspace(workspace: str | Path) -> dict:
|
||||
repo_root = find_git_repo_root(workspace)
|
||||
info = _setup_agent_worktree(str(repo_root))
|
||||
path = info.get("path")
|
||||
branch = info.get("branch")
|
||||
if not path or not branch:
|
||||
raise RuntimeError("Hermes Agent returned incomplete worktree metadata")
|
||||
return {
|
||||
"path": str(Path(path).expanduser().resolve()),
|
||||
"branch": str(branch),
|
||||
"repo_root": str(Path(info.get("repo_root") or repo_root).expanduser().resolve()),
|
||||
"created_at": time.time(),
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 180 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 176 KiB |
@@ -151,6 +151,11 @@ const LOCALES = {
|
||||
model_group_configured: 'Configured',
|
||||
ws_search_placeholder: 'Search workspaces…',
|
||||
ws_no_results: 'No workspaces found',
|
||||
workspace_new_worktree_conversation: 'New conversation in worktree',
|
||||
workspace_new_worktree_conversation_meta: 'Create an isolated git worktree for this workspace.',
|
||||
workspace_worktree_created: 'Worktree conversation created',
|
||||
workspace_worktree_failed: 'Worktree creation failed: ',
|
||||
session_worktree_badge: 'Worktree',
|
||||
model_scope_advisory: 'Applies to this conversation from your next message.',
|
||||
model_scope_toast: 'Applies to this conversation from your next message.',
|
||||
// commands.js
|
||||
@@ -1236,6 +1241,11 @@ const LOCALES = {
|
||||
model_group_configured: '設定済み',
|
||||
ws_search_placeholder: 'ワークスペースを検索…',
|
||||
ws_no_results: 'ワークスペースが見つかりません',
|
||||
workspace_new_worktree_conversation: 'worktree で新しい会話',
|
||||
workspace_new_worktree_conversation_meta: 'このワークスペース用に隔離された git worktree を作成します。',
|
||||
workspace_worktree_created: 'worktree 会話を作成しました',
|
||||
workspace_worktree_failed: 'worktree の作成に失敗しました: ',
|
||||
session_worktree_badge: 'Worktree',
|
||||
model_scope_advisory: '次回のメッセージからこの会話に適用されます。',
|
||||
model_scope_toast: '次回のメッセージからこの会話に適用されます。',
|
||||
// commands.js
|
||||
@@ -2383,6 +2393,11 @@ const LOCALES = {
|
||||
model_group_configured: 'Настроенные',
|
||||
ws_search_placeholder: 'Поиск рабочих пространств…',
|
||||
ws_no_results: 'Рабочие пространства не найдены',
|
||||
workspace_new_worktree_conversation: 'Новый разговор в worktree',
|
||||
workspace_new_worktree_conversation_meta: 'Создать изолированный git worktree для этого рабочего пространства.',
|
||||
workspace_worktree_created: 'Разговор в worktree создан',
|
||||
workspace_worktree_failed: 'Не удалось создать worktree: ',
|
||||
session_worktree_badge: 'Worktree',
|
||||
model_search_placeholder: 'Поиск моделей…',
|
||||
model_scope_advisory: 'Применяется к этой беседе со следующего сообщения.',
|
||||
session_toolsets: 'Session Toolsets', // TODO: translate
|
||||
@@ -3320,6 +3335,11 @@ const LOCALES = {
|
||||
model_group_configured: 'Configurados',
|
||||
ws_search_placeholder: 'Buscar espacios de trabajo…',
|
||||
ws_no_results: 'No se encontraron espacios de trabajo',
|
||||
workspace_new_worktree_conversation: 'Nueva conversación en worktree',
|
||||
workspace_new_worktree_conversation_meta: 'Crear un git worktree aislado para este espacio de trabajo.',
|
||||
workspace_worktree_created: 'Conversación en worktree creada',
|
||||
workspace_worktree_failed: 'Error al crear worktree: ',
|
||||
session_worktree_badge: 'Worktree',
|
||||
session_toolsets: 'Session Toolsets', // TODO: translate
|
||||
session_toolsets_desc: 'Restrict available tools for this session (blank = use global config)', // TODO: translate
|
||||
session_toolsets_global: 'Global (default)', // TODO: translate
|
||||
@@ -4882,6 +4902,11 @@ const LOCALES = {
|
||||
model_group_configured: 'Konfiguriert',
|
||||
ws_search_placeholder: 'Arbeitsbereiche suchen…',
|
||||
ws_no_results: 'Keine Arbeitsbereiche gefunden',
|
||||
workspace_new_worktree_conversation: 'Neue Unterhaltung in Worktree',
|
||||
workspace_new_worktree_conversation_meta: 'Erstellt einen isolierten git worktree für diesen Arbeitsbereich.',
|
||||
workspace_worktree_created: 'Worktree-Unterhaltung erstellt',
|
||||
workspace_worktree_failed: 'Worktree-Erstellung fehlgeschlagen: ',
|
||||
session_worktree_badge: 'Worktree',
|
||||
session_toolsets: 'Session Toolsets', // TODO: translate
|
||||
session_toolsets_desc: 'Restrict available tools for this session (blank = use global config)', // TODO: translate
|
||||
session_toolsets_global: 'Global (default)', // TODO: translate
|
||||
@@ -5358,6 +5383,11 @@ const LOCALES = {
|
||||
model_group_configured: '已配置',
|
||||
ws_search_placeholder: '搜索工作区…',
|
||||
ws_no_results: '未找到工作区',
|
||||
workspace_new_worktree_conversation: '在 worktree 中新建对话',
|
||||
workspace_new_worktree_conversation_meta: '为此工作区创建隔离的 git worktree。',
|
||||
workspace_worktree_created: '已创建 worktree 对话',
|
||||
workspace_worktree_failed: 'Worktree 创建失败:',
|
||||
session_worktree_badge: 'Worktree',
|
||||
session_toolsets: 'Session 工具集',
|
||||
session_toolsets_desc: '限制此会话可用工具(留空 = 使用全局配置)',
|
||||
session_toolsets_global: '全局(默认)',
|
||||
@@ -7451,6 +7481,11 @@ const LOCALES = {
|
||||
model_group_configured: 'Configurados',
|
||||
ws_search_placeholder: 'Buscar espaços de trabalho…',
|
||||
ws_no_results: 'Nenhum espaço de trabalho encontrado',
|
||||
workspace_new_worktree_conversation: 'Nova conversa em worktree',
|
||||
workspace_new_worktree_conversation_meta: 'Cria um git worktree isolado para este espaço de trabalho.',
|
||||
workspace_worktree_created: 'Conversa em worktree criada',
|
||||
workspace_worktree_failed: 'Falha ao criar worktree: ',
|
||||
session_worktree_badge: 'Worktree',
|
||||
// commands.js
|
||||
cmd_clear: 'Limpar mensagens da conversa',
|
||||
cmd_compress: 'Comprimir manualmente o contexto (uso: /compress [tópico])',
|
||||
@@ -8407,6 +8442,11 @@ const LOCALES = {
|
||||
model_group_configured: '구성됨',
|
||||
ws_search_placeholder: '워크스페이스 검색…',
|
||||
ws_no_results: '워크스페이스를 찾을 수 없습니다',
|
||||
workspace_new_worktree_conversation: 'worktree에서 새 대화',
|
||||
workspace_new_worktree_conversation_meta: '이 워크스페이스용 격리된 git worktree를 만듭니다.',
|
||||
workspace_worktree_created: 'worktree 대화가 생성되었습니다',
|
||||
workspace_worktree_failed: 'worktree 생성 실패: ',
|
||||
session_worktree_badge: 'Worktree',
|
||||
model_scope_advisory: '다음 메시지부터 이 대화에 적용됩니다.',
|
||||
model_scope_toast: '다음 메시지부터 이 대화에 적용됩니다.',
|
||||
// commands.js
|
||||
|
||||
@@ -3673,6 +3673,24 @@ function renderWorkspaceDropdownInto(dd, workspaces, currentWs){
|
||||
|
||||
// ── Footer actions ────────────────────────────────────────────────────────
|
||||
dd.appendChild(document.createElement('div')).className='ws-divider';
|
||||
dd.appendChild(_renderWorkspaceAction(
|
||||
t('workspace_new_worktree_conversation'),
|
||||
t('workspace_new_worktree_conversation_meta'),
|
||||
li('git-branch',12),
|
||||
async()=>{
|
||||
closeWsDropdown();
|
||||
try{
|
||||
await newSession(false,{worktree:true});
|
||||
await renderSessionList();
|
||||
const msg=$('msg');
|
||||
if(msg)msg.focus();
|
||||
showToast(t('workspace_worktree_created'));
|
||||
}catch(e){
|
||||
showToast(t('workspace_worktree_failed')+(e&&e.message?e.message:e),'error');
|
||||
}
|
||||
}
|
||||
));
|
||||
dd.appendChild(document.createElement('div')).className='ws-divider';
|
||||
dd.appendChild(_renderWorkspaceAction(
|
||||
t('workspace_choose_path'),
|
||||
t('workspace_choose_path_meta'),
|
||||
|
||||
+10
-1
@@ -340,7 +340,7 @@ function _markPollingCompletionUnreadTransitions(sessions) {
|
||||
}
|
||||
}
|
||||
|
||||
async function newSession(flash){
|
||||
async function newSession(flash, options={}){
|
||||
updateQueueBadge();
|
||||
S.toolCalls=[];
|
||||
clearLiveToolCards();
|
||||
@@ -371,6 +371,7 @@ async function newSession(flash){
|
||||
workspace:inheritWs,
|
||||
profile:S.activeProfile||'default',
|
||||
};
|
||||
if(options&&options.worktree) reqBody.worktree=true;
|
||||
if(_activeProject&&_activeProject!==NO_PROJECT_FILTER) reqBody.project_id=_activeProject;
|
||||
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify(reqBody)});
|
||||
S.session=data.session;S.messages=data.session.messages||[];
|
||||
@@ -2581,6 +2582,14 @@ function renderSessionListFromCache(){
|
||||
pinInd.innerHTML=ICONS.pin;
|
||||
titleRow.appendChild(pinInd);
|
||||
}
|
||||
if(s.worktree_path){
|
||||
const wtInd=document.createElement('span');
|
||||
wtInd.className='session-worktree-indicator';
|
||||
wtInd.innerHTML=li('git-branch',12);
|
||||
const wtLabel=(typeof t==='function'?t('session_worktree_badge'):'Worktree');
|
||||
wtInd.title=`${wtLabel}: ${s.worktree_branch||s.worktree_path}`;
|
||||
titleRow.appendChild(wtInd);
|
||||
}
|
||||
// Parent session indicator for forked/branched sessions (#465)
|
||||
if(s.parent_session_id){
|
||||
const branchInd=document.createElement('span');
|
||||
|
||||
+12
-3
@@ -2641,7 +2641,8 @@ main.main.showing-logs > #mainLogs{display:flex;}
|
||||
.session-pin-indicator svg{width:10px;height:10px;}
|
||||
|
||||
/* ── Fork lineage indicator (inline, subtle until row focus/hover) ── */
|
||||
.session-branch-indicator{
|
||||
.session-branch-indicator,
|
||||
.session-worktree-indicator{
|
||||
flex-shrink:0;
|
||||
width:12px;
|
||||
height:12px;
|
||||
@@ -2654,14 +2655,22 @@ main.main.showing-logs > #mainLogs{display:flex;}
|
||||
pointer-events:none;
|
||||
transition:opacity .15s ease,color .15s ease;
|
||||
}
|
||||
.session-branch-indicator svg{width:12px;height:12px;}
|
||||
.session-branch-indicator svg,
|
||||
.session-worktree-indicator svg{width:12px;height:12px;}
|
||||
.session-item:hover .session-branch-indicator,
|
||||
.session-item:hover .session-worktree-indicator,
|
||||
.session-item:focus-within .session-branch-indicator,
|
||||
.session-item:focus-within .session-worktree-indicator,
|
||||
.session-item.menu-open .session-branch-indicator{
|
||||
opacity:.85;
|
||||
color:var(--text);
|
||||
}
|
||||
.session-item.active .session-branch-indicator{color:var(--accent-text);}
|
||||
.session-item.menu-open .session-worktree-indicator{
|
||||
opacity:.85;
|
||||
color:var(--text);
|
||||
}
|
||||
.session-item.active .session-branch-indicator,
|
||||
.session-item.active .session-worktree-indicator{color:var(--accent-text);}
|
||||
|
||||
/* ── Cron alert badge ── */
|
||||
.cron-badge{position:absolute;top:2px;right:2px;background:#e53e3e;color:#fff;font-size:9px;font-weight:700;min-width:14px;height:14px;line-height:14px;text-align:center;border-radius:7px;padding:0 3px;}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import json
|
||||
import subprocess
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import api.models as models
|
||||
from api.models import SESSIONS, Session, new_session
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_sessions(tmp_path, monkeypatch):
|
||||
session_dir = tmp_path / "sessions"
|
||||
session_dir.mkdir()
|
||||
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
|
||||
SESSIONS.clear()
|
||||
yield session_dir
|
||||
SESSIONS.clear()
|
||||
|
||||
|
||||
def test_worktree_metadata_round_trips_through_session_file(_isolate_sessions):
|
||||
s = Session(
|
||||
session_id="worktree001",
|
||||
workspace=str(_isolate_sessions.parent / "repo" / ".worktrees" / "hermes-1234"),
|
||||
worktree_path=str(_isolate_sessions.parent / "repo" / ".worktrees" / "hermes-1234"),
|
||||
worktree_branch="hermes/hermes-1234",
|
||||
worktree_repo_root=str(_isolate_sessions.parent / "repo"),
|
||||
worktree_created_at=123.5,
|
||||
)
|
||||
s.save()
|
||||
|
||||
raw = json.loads(s.path.read_text(encoding="utf-8"))
|
||||
assert raw["worktree_path"].endswith(".worktrees/hermes-1234")
|
||||
assert raw["worktree_branch"] == "hermes/hermes-1234"
|
||||
assert raw["worktree_repo_root"].endswith("repo")
|
||||
assert raw["worktree_created_at"] == 123.5
|
||||
|
||||
loaded = Session.load("worktree001")
|
||||
assert loaded.worktree_path == s.worktree_path
|
||||
assert loaded.worktree_branch == "hermes/hermes-1234"
|
||||
assert loaded.worktree_repo_root == s.worktree_repo_root
|
||||
assert loaded.worktree_created_at == 123.5
|
||||
assert loaded.compact()["worktree_branch"] == "hermes/hermes-1234"
|
||||
|
||||
|
||||
def test_new_session_with_worktree_info_persists_immediately(_isolate_sessions):
|
||||
repo = _isolate_sessions.parent / "repo"
|
||||
worktree = repo / ".worktrees" / "hermes-abcd1234"
|
||||
worktree.mkdir(parents=True)
|
||||
|
||||
s = new_session(
|
||||
workspace=str(worktree),
|
||||
worktree_info={
|
||||
"path": str(worktree),
|
||||
"branch": "hermes/hermes-abcd1234",
|
||||
"repo_root": str(repo),
|
||||
"created_at": 456.0,
|
||||
},
|
||||
)
|
||||
|
||||
assert s.path.exists(), (
|
||||
"worktree-backed sessions must be persisted at creation time so the "
|
||||
"real filesystem worktree is not orphaned by a browser/server restart"
|
||||
)
|
||||
assert s.worktree_path == str(worktree.resolve())
|
||||
assert s.worktree_branch == "hermes/hermes-abcd1234"
|
||||
assert s.worktree_repo_root == str(repo.resolve())
|
||||
assert s.worktree_created_at == 456.0
|
||||
|
||||
|
||||
def test_empty_worktree_session_remains_visible_in_sidebar(_isolate_sessions):
|
||||
repo = _isolate_sessions.parent / "repo"
|
||||
worktree = repo / ".worktrees" / "hermes-visible"
|
||||
worktree.mkdir(parents=True)
|
||||
|
||||
s = new_session(
|
||||
workspace=str(worktree),
|
||||
worktree_info={
|
||||
"path": str(worktree),
|
||||
"branch": "hermes/hermes-visible",
|
||||
"repo_root": str(repo),
|
||||
"created_at": 789.0,
|
||||
},
|
||||
)
|
||||
|
||||
ids = {row["session_id"] for row in models.all_sessions()}
|
||||
assert s.session_id in ids, (
|
||||
"worktree-backed sessions represent real filesystem state immediately "
|
||||
"and must survive the empty-session sidebar filter"
|
||||
)
|
||||
|
||||
|
||||
def test_find_git_repo_root_uses_git_from_nested_workspace(tmp_path):
|
||||
from api.worktrees import find_git_repo_root
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
nested = repo / "apps" / "web"
|
||||
nested.mkdir(parents=True)
|
||||
subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True)
|
||||
|
||||
assert find_git_repo_root(nested) == repo.resolve()
|
||||
|
||||
|
||||
def test_find_git_repo_root_rejects_non_git_workspace(tmp_path):
|
||||
from api.worktrees import find_git_repo_root
|
||||
|
||||
with pytest.raises(ValueError, match="not inside a git repository"):
|
||||
find_git_repo_root(tmp_path)
|
||||
|
||||
|
||||
def test_create_worktree_for_workspace_calls_agent_setup_with_repo_root(tmp_path, monkeypatch):
|
||||
import api.worktrees as worktrees
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
nested = repo / "src"
|
||||
nested.mkdir(parents=True)
|
||||
subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True)
|
||||
seen = {}
|
||||
|
||||
def fake_setup(repo_root):
|
||||
seen["repo_root"] = repo_root
|
||||
return {
|
||||
"path": str(repo / ".worktrees" / "hermes-test"),
|
||||
"branch": "hermes/hermes-test",
|
||||
"repo_root": str(repo),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(worktrees, "_setup_agent_worktree", fake_setup)
|
||||
now = time.time()
|
||||
|
||||
info = worktrees.create_worktree_for_workspace(nested)
|
||||
|
||||
assert seen["repo_root"] == str(repo.resolve())
|
||||
assert info["path"].endswith(".worktrees/hermes-test")
|
||||
assert info["branch"] == "hermes/hermes-test"
|
||||
assert info["repo_root"] == str(repo.resolve())
|
||||
assert info["created_at"] >= now
|
||||
|
||||
|
||||
def test_session_new_route_creates_worktree_backed_session(tmp_path, monkeypatch):
|
||||
import api.routes as routes
|
||||
import api.worktrees as worktrees
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
worktree = repo / ".worktrees" / "hermes-route"
|
||||
repo.mkdir()
|
||||
worktree.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"read_body",
|
||||
lambda handler: {
|
||||
"workspace": str(repo),
|
||||
"worktree": True,
|
||||
"profile": "default",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda raw: repo if raw == str(repo) else raw)
|
||||
monkeypatch.setattr(
|
||||
worktrees,
|
||||
"create_worktree_for_workspace",
|
||||
lambda workspace: {
|
||||
"path": str(worktree),
|
||||
"branch": "hermes/hermes-route",
|
||||
"repo_root": str(repo),
|
||||
"created_at": 321.0,
|
||||
},
|
||||
)
|
||||
captured = {}
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"j",
|
||||
lambda handler, payload, status=200, extra_headers=None: captured.update(
|
||||
payload=payload,
|
||||
status=status,
|
||||
) or True,
|
||||
)
|
||||
|
||||
assert routes.handle_post(object(), SimpleNamespace(path="/api/session/new")) is True
|
||||
assert captured["status"] == 200
|
||||
session = captured["payload"]["session"]
|
||||
assert session["workspace"] == str(worktree.resolve())
|
||||
assert session["worktree_path"] == str(worktree.resolve())
|
||||
assert session["worktree_branch"] == "hermes/hermes-route"
|
||||
@@ -0,0 +1,44 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path):
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_session_new_route_accepts_worktree_flag_and_uses_worktree_info():
|
||||
src = read("api/routes.py")
|
||||
assert "create_worktree_for_workspace" in src
|
||||
assert 'body.get("worktree")' in src or "body.get('worktree')" in src
|
||||
assert "worktree_info=" in src
|
||||
|
||||
|
||||
def test_new_session_request_can_include_worktree_flag():
|
||||
src = read("static/sessions.js")
|
||||
assert "async function newSession(flash, options={})" in src
|
||||
assert "reqBody.worktree=true" in src
|
||||
|
||||
|
||||
def test_workspace_dropdown_exposes_new_worktree_conversation_action():
|
||||
src = read("static/panels.js")
|
||||
assert "workspace_new_worktree_conversation" in src
|
||||
assert "workspace_new_worktree_conversation_meta" in src
|
||||
assert "newSession(false,{worktree:true})" in src
|
||||
assert "li('git-branch',12)" in src
|
||||
|
||||
|
||||
def test_session_sidebar_renders_worktree_indicator():
|
||||
src = read("static/sessions.js")
|
||||
assert "session-worktree-indicator" in src
|
||||
assert "s.worktree_path" in src
|
||||
assert "s.worktree_branch" in src
|
||||
|
||||
|
||||
def test_worktree_indicator_styles_and_i18n_exist():
|
||||
css = read("static/style.css")
|
||||
i18n = read("static/i18n.js")
|
||||
assert ".session-worktree-indicator" in css
|
||||
assert "workspace_new_worktree_conversation" in i18n
|
||||
assert "session_worktree_badge" in i18n
|
||||
Reference in new issue
Block a user