diff --git a/CHANGELOG.md b/CHANGELOG.md index 629543fcc2a..bda9843cd11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. The underlying Agent helper may add `.worktrees/` to the repository `.gitignore` the first time a worktree is created for that repo (#1955). + ## [v0.51.43] — 2026-05-11 — Release S (fused community PR — desktop sidebar collapse) ### Added diff --git a/api/models.py b/api/models.py index 62099f05aed..b15d5531743 100644 --- a/api/models.py +++ b/api/models.py @@ -335,6 +335,10 @@ def __init__(self, session_id: str=None, title: str='Untitled', 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 @@ def __init__(self, session_id: str=None, title: str='Untitled', 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 @@ def save(self, touch_updated_at: bool = True, skip_index: bool = False) -> None: '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 @@ def compact(self, include_runtime=False, active_stream_ids=None) -> dict: # 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: diff --git a/api/routes.py b/api/routes.py index 18354885111..f6908a9bfbe 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3847,8 +3847,26 @@ def handle_post(handler, parsed) -> bool: if parsed.path == "/api/session/new": try: workspace = str(resolve_trusted_workspace(body.get("workspace"))) if body.get("workspace") else None - except ValueError as e: + except (TypeError, 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 + if not base_workspace: + base_workspace = str(resolve_trusted_workspace(get_last_workspace())) + worktree_info = create_worktree_for_workspace(base_workspace) + workspace = worktree_info["path"] + except (TypeError, 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 +3879,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}}) diff --git a/api/worktrees.py b/api/worktrees.py new file mode 100644 index 00000000000..330a43858b8 --- /dev/null +++ b/api/worktrees.py @@ -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(), + } diff --git a/docs/pr-media/1955/after-workspace-menu.png b/docs/pr-media/1955/after-workspace-menu.png new file mode 100644 index 00000000000..a3db27696ee Binary files /dev/null and b/docs/pr-media/1955/after-workspace-menu.png differ diff --git a/docs/pr-media/1955/before-workspace-menu.png b/docs/pr-media/1955/before-workspace-menu.png new file mode 100644 index 00000000000..3906dce4d3f Binary files /dev/null and b/docs/pr-media/1955/before-workspace-menu.png differ diff --git a/static/i18n.js b/static/i18n.js index 2dec495344f..208206312bc 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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 diff --git a/static/panels.js b/static/panels.js index 06dc13ebc06..d2517582e1d 100644 --- a/static/panels.js +++ b/static/panels.js @@ -3695,6 +3695,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'), diff --git a/static/sessions.js b/static/sessions.js index 2251b902914..023cf845ecc 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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'); diff --git a/static/style.css b/static/style.css index 0c2f6ea2b6b..7e87920fd45 100644 --- a/static/style.css +++ b/static/style.css @@ -2657,7 +2657,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; @@ -2670,14 +2671,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;} diff --git a/tests/test_issue1955_worktree_sessions.py b/tests/test_issue1955_worktree_sessions.py new file mode 100644 index 00000000000..c1c623e7e16 --- /dev/null +++ b/tests/test_issue1955_worktree_sessions.py @@ -0,0 +1,241 @@ +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" + + +def test_session_new_worktree_fallback_workspace_is_resolved(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) + seen = {"resolved": []} + + monkeypatch.setattr(routes, "_check_csrf", lambda handler: True) + monkeypatch.setattr( + routes, + "read_body", + lambda handler: { + "worktree": True, + "profile": "default", + }, + ) + monkeypatch.setattr(routes, "get_last_workspace", lambda: str(repo)) + + def fake_resolve(raw): + seen["resolved"].append(raw) + return repo + + monkeypatch.setattr(routes, "resolve_trusted_workspace", fake_resolve) + 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 seen["resolved"] == [str(repo)] + assert captured["status"] == 200 + session = captured["payload"]["session"] + assert session["workspace"] == str(worktree.resolve()) diff --git a/tests/test_issue1955_worktree_ui_static.py b/tests/test_issue1955_worktree_ui_static.py new file mode 100644 index 00000000000..d160d5a8412 --- /dev/null +++ b/tests/test_issue1955_worktree_ui_static.py @@ -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