mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-24 18:50:15 +00:00
Merge pull request #2156 into stage-346
Issue #2057 Slice 2: Add guarded worktree remove action
This commit is contained in:
+3
-2
@@ -156,10 +156,11 @@ Python stdlib ThreadingHTTPServer (from http.server). Each HTTP request runs in
|
||||
thread. The Handler class subclasses BaseHTTPRequestHandler with two methods:
|
||||
|
||||
do_GET Routes: /, /health, /api/session, /api/sessions, /api/list,
|
||||
/api/chat/stream, /api/file, /api/approval/pending
|
||||
/api/chat/stream, /api/file, /api/approval/pending,
|
||||
/api/session/worktree/status
|
||||
do_POST Routes: /api/upload, /api/session/new, /api/session/update,
|
||||
/api/session/delete, /api/chat/start, /api/chat,
|
||||
/api/approval/respond
|
||||
/api/approval/respond, /api/session/worktree/remove
|
||||
|
||||
Routing is a flat if/elif chain inside each method. No routing framework.
|
||||
|
||||
|
||||
@@ -3173,6 +3173,7 @@ def handle_get(handler, parsed) -> bool:
|
||||
if parsed.path.startswith("/static/"):
|
||||
return _serve_static(handler, parsed)
|
||||
|
||||
|
||||
if parsed.path == "/api/session/worktree/status":
|
||||
query = parse_qs(parsed.query)
|
||||
sid = query.get("session_id", [""])[0]
|
||||
@@ -4391,6 +4392,28 @@ def handle_post(handler, parsed) -> bool:
|
||||
logger.debug("Failed to close workspace terminal after workspace update")
|
||||
set_last_workspace(new_ws)
|
||||
return j(handler, {"session": s.compact() | {"messages": s.messages}})
|
||||
if parsed.path == "/api/session/worktree/remove":
|
||||
sid = body.get("session_id", "")
|
||||
if not sid or not isinstance(sid, str) or not sid.strip():
|
||||
return bad(handler, "session_id must be a non-empty string", status=400)
|
||||
sid = sid.strip()
|
||||
if not all(c in '0123456789abcdefghijklmnopqrstuvwxyz_' for c in sid):
|
||||
return bad(handler, "Invalid session_id", 400)
|
||||
try:
|
||||
s = get_session(sid, metadata_only=True)
|
||||
except KeyError:
|
||||
return bad(handler, "Session not found", status=404)
|
||||
force = bool(body.get("force", False))
|
||||
try:
|
||||
from api.worktrees import remove_worktree_for_session
|
||||
|
||||
result = remove_worktree_for_session(s, force=force)
|
||||
return j(handler, result)
|
||||
except ValueError as exc:
|
||||
return bad(handler, str(exc), status=400)
|
||||
except Exception as exc:
|
||||
logger.exception("failed to remove worktree for session %s", sid)
|
||||
return bad(handler, _sanitize_error(exc), status=500)
|
||||
|
||||
if parsed.path == "/api/session/delete":
|
||||
sid = body.get("session_id", "")
|
||||
|
||||
@@ -201,6 +201,102 @@ def worktree_status_for_session(session) -> dict:
|
||||
return status
|
||||
|
||||
|
||||
def remove_worktree_for_session(session, *, force: bool = False) -> dict:
|
||||
"""Remove a session's git worktree from disk.
|
||||
|
||||
Returns status dict with keys: ok, removed_path, warnings.
|
||||
Raises ValueError for terminal blockers (locked by stream/terminal,
|
||||
dirty with force=False).
|
||||
"""
|
||||
raw_path = getattr(session, "worktree_path", None)
|
||||
if not raw_path:
|
||||
raise ValueError("Session is not worktree-backed")
|
||||
|
||||
worktree_path = _resolve_path(raw_path)
|
||||
if worktree_path is None:
|
||||
raise ValueError("Session is not worktree-backed")
|
||||
|
||||
# Read current status before removal
|
||||
status = worktree_status_for_session(session)
|
||||
|
||||
if not status["exists"]:
|
||||
return {
|
||||
"ok": True,
|
||||
"removed_path": str(worktree_path),
|
||||
"warnings": ["Worktree directory no longer exists on disk."],
|
||||
}
|
||||
|
||||
warnings = []
|
||||
|
||||
# Guard: locked by stream
|
||||
if status["locked_by_stream"]:
|
||||
raise ValueError("Worktree is locked by an active streaming session")
|
||||
|
||||
# Guard: locked by terminal
|
||||
if status["locked_by_terminal"]:
|
||||
raise ValueError("Worktree is locked by an active terminal session")
|
||||
|
||||
# Guard: local changes and unpushed commits without explicit force.
|
||||
if status["dirty"] and not force:
|
||||
raise ValueError(
|
||||
"Worktree has uncommitted changes. Use force=true to override."
|
||||
)
|
||||
if status["untracked_count"] > 0:
|
||||
if force:
|
||||
warnings.append(
|
||||
f"{status['untracked_count']} untracked file(s) will be removed."
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Worktree has {status['untracked_count']} untracked file(s). "
|
||||
"Use force=true to override."
|
||||
)
|
||||
ahead = int((status.get("ahead_behind") or {}).get("ahead") or 0)
|
||||
if ahead > 0:
|
||||
if force:
|
||||
warnings.append(f"{ahead} unpushed commit(s) will be removed.")
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Worktree has {ahead} unpushed commit(s). "
|
||||
"Use force=true to override."
|
||||
)
|
||||
|
||||
# Remove the worktree — must run from the repo root, not the worktree dir
|
||||
repo_root = getattr(session, "worktree_repo_root", None)
|
||||
if not repo_root:
|
||||
raise ValueError("Session missing worktree_repo_root")
|
||||
try:
|
||||
remove_args = ["worktree", "remove"]
|
||||
if force:
|
||||
remove_args.append("--force")
|
||||
remove_args.append(str(worktree_path))
|
||||
result = _run_git(remove_args, str(repo_root), timeout=10)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise ValueError(f"Failed to remove worktree: {exc}") from exc
|
||||
|
||||
if result.returncode != 0:
|
||||
stderr = (result.stderr or "").strip().split("\n")[-1]
|
||||
raise ValueError(
|
||||
f"git worktree remove failed: {stderr or result.stdout.strip()}"
|
||||
)
|
||||
|
||||
# Prune in case the worktree dir was already gone
|
||||
try:
|
||||
_run_git(
|
||||
["worktree", "prune"],
|
||||
str(repo_root),
|
||||
timeout=5,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"removed_path": str(worktree_path),
|
||||
"warnings": warnings or None,
|
||||
}
|
||||
|
||||
|
||||
def find_git_repo_root(workspace: str | Path) -> Path:
|
||||
"""Return the enclosing git repo root for *workspace*.
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
+154
@@ -430,6 +430,20 @@ const LOCALES = {
|
||||
session_delete_worktree_confirm: (path) => `Delete this conversation? The worktree at ${path} will remain on disk.`,
|
||||
session_deleted: 'Conversation deleted',
|
||||
session_deleted_worktree: 'Conversation deleted. Worktree remains on disk.',
|
||||
session_worktree_remove: 'Remove worktree',
|
||||
session_worktree_remove_desc: (path) => `Delete the git worktree at ${path} from disk`,
|
||||
session_worktree_remove_confirm: (path) => `Remove git worktree from disk?\n\nPath: ${path}\n\nThis will delete the entire worktree directory. Session data remains in WebUI.`,
|
||||
session_worktree_remove_not_exists: (path) => `The worktree at ${path} no longer exists on disk.`,
|
||||
session_worktree_remove_confirm_label: 'Remove',
|
||||
session_worktree_removed: 'Worktree removed.',
|
||||
session_worktree_remove_failed: 'Failed to remove worktree: ',
|
||||
session_worktree_remove_status_failed: 'Failed to read worktree status: ',
|
||||
session_worktree_remove_locked_by_stream: 'Cannot remove — an active streaming session is using this worktree.',
|
||||
session_worktree_remove_locked_by_terminal: 'Cannot remove — an active terminal session is using this worktree.',
|
||||
session_worktree_remove_unsafe_blocked: 'Resolve local changes or unpushed commits before removing this worktree.',
|
||||
session_worktree_remove_dirty_warning: 'WARNING: This worktree has uncommitted changes which will be lost.',
|
||||
session_worktree_remove_untracked_warning: (count) => `${count} untracked file(s) will be permanently deleted.`,
|
||||
session_worktree_remove_ahead_warning: (ahead) => `${ahead} unpushed commit(s) will be lost.`,
|
||||
session_select_mode: 'Select',
|
||||
session_select_mode_desc: 'Select conversations to batch manage',
|
||||
session_select_all: 'Select all',
|
||||
@@ -1541,6 +1555,20 @@ const LOCALES = {
|
||||
session_delete_worktree_confirm: (path) => `Eliminare questa conversazione? Il worktree in ${path} rimarrà su disco.`,
|
||||
session_deleted: 'Conversazione eliminata',
|
||||
session_deleted_worktree: 'Conversazione eliminata. Il worktree rimane su disco.',
|
||||
session_worktree_remove: 'Rimuovi worktree',
|
||||
session_worktree_remove_desc: (path) => `Elimina il git worktree in ${path} dal disco`,
|
||||
session_worktree_remove_confirm: (path) => `Rimuovere il git worktree dal disco?\n\nPercorso: ${path}\n\nVerrà eliminata l'intera directory del worktree. I dati della sessione restano in WebUI.`,
|
||||
session_worktree_remove_not_exists: (path) => `Il worktree in ${path} non esiste più sul disco.`,
|
||||
session_worktree_remove_confirm_label: 'Rimuovi',
|
||||
session_worktree_removed: 'Worktree rimosso.',
|
||||
session_worktree_remove_failed: 'Rimozione worktree fallita: ',
|
||||
session_worktree_remove_status_failed: 'Lettura stato worktree fallita: ',
|
||||
session_worktree_remove_locked_by_stream: 'Impossibile rimuovere — una sessione di streaming attiva sta usando questo worktree.',
|
||||
session_worktree_remove_locked_by_terminal: 'Impossibile rimuovere — una sessione terminale attiva sta usando questo worktree.',
|
||||
session_worktree_remove_unsafe_blocked: 'Risolvi le modifiche locali o i commit non inviati prima di rimuovere questo worktree.',
|
||||
session_worktree_remove_dirty_warning: 'ATTENZIONE: Questo worktree ha modifiche non committate che andranno perse.',
|
||||
session_worktree_remove_untracked_warning: (count) => `${count} file non tracciati verranno eliminati definitivamente.`,
|
||||
session_worktree_remove_ahead_warning: (ahead) => `${ahead} commit non inviati andranno persi.`,
|
||||
session_select_mode: 'Seleziona',
|
||||
session_select_mode_desc: 'Seleziona conversazioni per gestione in blocco',
|
||||
session_select_all: 'Seleziona tutto',
|
||||
@@ -2644,6 +2672,20 @@ const LOCALES = {
|
||||
session_delete_worktree_confirm: (path) => `この会話を削除しますか? ${path} の worktree はディスク上に残ります。`,
|
||||
session_deleted: '会話を削除しました',
|
||||
session_deleted_worktree: '会話を削除しました。Worktree はディスク上に残ります。',
|
||||
session_worktree_remove: 'ワークツリーを削除',
|
||||
session_worktree_remove_desc: (path) => `${path} のgitワークツリーをディスクから削除します`,
|
||||
session_worktree_remove_confirm: (path) => `gitワークツリーをディスクから削除しますか?\n\nパス: ${path}\n\nワークツリーディレクトリ全体が削除されます。セッションデータはWebUIに残ります。`,
|
||||
session_worktree_remove_not_exists: (path) => `${path} のワークツリーはディスク上に存在しません。`,
|
||||
session_worktree_remove_confirm_label: '削除',
|
||||
session_worktree_removed: 'ワークツリーを削除しました。',
|
||||
session_worktree_remove_failed: 'ワークツリーの削除に失敗: ',
|
||||
session_worktree_remove_status_failed: 'ワークツリー状態の読み取りに失敗: ',
|
||||
session_worktree_remove_locked_by_stream: '削除できません — アクティブなストリーミングセッションがこのワークツリーを使用中です。',
|
||||
session_worktree_remove_locked_by_terminal: '削除できません — アクティブな端末セッションがこのワークツリーを使用中です。',
|
||||
session_worktree_remove_unsafe_blocked: 'このワークツリーを削除する前に、ローカル変更または未プッシュのコミットを解消してください。',
|
||||
session_worktree_remove_dirty_warning: '警告: このワークツリーにはコミットされていない変更があり、失われます。',
|
||||
session_worktree_remove_untracked_warning: (count) => `${count}件の追跡されていないファイルが完全に削除されます。`,
|
||||
session_worktree_remove_ahead_warning: (ahead) => `${ahead}件の未プッシュコミットが失われます。`,
|
||||
session_select_mode: '選択',
|
||||
session_select_mode_desc: '会話を選択して一括管理',
|
||||
session_select_all: 'すべて選択',
|
||||
@@ -4190,6 +4232,20 @@ const LOCALES = {
|
||||
session_delete_worktree_confirm: (path) => `Delete this conversation? The worktree at ${path} will remain on disk.`,
|
||||
session_deleted: 'Conversation deleted',
|
||||
session_deleted_worktree: 'Conversation deleted. Worktree remains on disk.',
|
||||
session_worktree_remove: 'Remove worktree',
|
||||
session_worktree_remove_desc: (path) => `Delete the git worktree at ${path} from disk`,
|
||||
session_worktree_remove_confirm: (path) => `Remove git worktree from disk?\n\nPath: ${path}\n\nThis will delete the entire worktree directory. Session data remains in WebUI.`,
|
||||
session_worktree_remove_not_exists: (path) => `The worktree at ${path} no longer exists on disk.`,
|
||||
session_worktree_remove_confirm_label: 'Remove',
|
||||
session_worktree_removed: 'Worktree removed.',
|
||||
session_worktree_remove_failed: 'Failed to remove worktree: ',
|
||||
session_worktree_remove_status_failed: 'Failed to read worktree status: ',
|
||||
session_worktree_remove_locked_by_stream: 'Cannot remove — an active streaming session is using this worktree.',
|
||||
session_worktree_remove_locked_by_terminal: 'Cannot remove — an active terminal session is using this worktree.',
|
||||
session_worktree_remove_unsafe_blocked: 'Resolve local changes or unpushed commits before removing this worktree.',
|
||||
session_worktree_remove_dirty_warning: 'WARNING: This worktree has uncommitted changes which will be lost.',
|
||||
session_worktree_remove_untracked_warning: (count) => `${count} untracked file(s) will be permanently deleted.`,
|
||||
session_worktree_remove_ahead_warning: (ahead) => `${ahead} unpushed commit(s) will be lost.`,
|
||||
session_duplicate: 'Duplicate conversation',
|
||||
session_duplicate_desc: 'Create a copy with the same workspace and model',
|
||||
session_duplicate_failed: 'Duplicate failed: ',
|
||||
@@ -5220,6 +5276,20 @@ const LOCALES = {
|
||||
session_delete_worktree_confirm: (path) => `¿Eliminar esta conversación? El worktree en ${path} permanecerá en disco.`,
|
||||
session_deleted: 'Conversación eliminada',
|
||||
session_deleted_worktree: 'Conversación eliminada. El worktree permanece en disco.',
|
||||
session_worktree_remove: 'Eliminar worktree',
|
||||
session_worktree_remove_desc: (path) => `Eliminar el git worktree en ${path} del disco`,
|
||||
session_worktree_remove_confirm: (path) => `¿Eliminar el git worktree del disco?\n\nRuta: ${path}\n\nSe eliminará todo el directorio del worktree. Los datos de la sesión permanecen en WebUI.`,
|
||||
session_worktree_remove_not_exists: (path) => `El worktree en ${path} ya no existe en el disco.`,
|
||||
session_worktree_remove_confirm_label: 'Eliminar',
|
||||
session_worktree_removed: 'Worktree eliminado.',
|
||||
session_worktree_remove_failed: 'Error al eliminar worktree: ',
|
||||
session_worktree_remove_status_failed: 'Error al leer el estado del worktree: ',
|
||||
session_worktree_remove_locked_by_stream: 'No se puede eliminar — una sesión de streaming activa está usando este worktree.',
|
||||
session_worktree_remove_locked_by_terminal: 'No se puede eliminar — una sesión de terminal activa está usando este worktree.',
|
||||
session_worktree_remove_unsafe_blocked: 'Resuelve los cambios locales o los commits no enviados antes de eliminar este worktree.',
|
||||
session_worktree_remove_dirty_warning: 'ADVERTENCIA: Este worktree tiene cambios no confirmados que se perderán.',
|
||||
session_worktree_remove_untracked_warning: (count) => `${count} archivo(s) no rastreados se eliminarán permanentemente.`,
|
||||
session_worktree_remove_ahead_warning: (ahead) => `${ahead} commit(s) no enviados se perderán.`,
|
||||
session_duplicate: 'Duplicate conversation',
|
||||
session_duplicate_desc: 'Create a copy with the same workspace and model',
|
||||
session_duplicate_failed: 'Duplicate failed: ',
|
||||
@@ -5990,6 +6060,20 @@ const LOCALES = {
|
||||
session_delete_worktree_confirm: (path) => `Diese Konversation löschen? Der Worktree unter ${path} bleibt auf der Festplatte.`,
|
||||
session_deleted: 'Konversation gelöscht',
|
||||
session_deleted_worktree: 'Konversation gelöscht. Der Worktree bleibt auf der Festplatte.',
|
||||
session_worktree_remove: 'Worktree entfernen',
|
||||
session_worktree_remove_desc: (path) => `Git-Worktree unter ${path} von der Festplatte löschen`,
|
||||
session_worktree_remove_confirm: (path) => `Git-Worktree von der Festplatte entfernen?\n\nPfad: ${path}\n\nDas gesamte Worktree-Verzeichnis wird gelöscht. Sitzungsdaten bleiben in WebUI.`,
|
||||
session_worktree_remove_not_exists: (path) => `Der Worktree unter ${path} existiert nicht mehr auf der Festplatte.`,
|
||||
session_worktree_remove_confirm_label: 'Entfernen',
|
||||
session_worktree_removed: 'Worktree entfernt.',
|
||||
session_worktree_remove_failed: 'Fehler beim Entfernen des Worktree: ',
|
||||
session_worktree_remove_status_failed: 'Fehler beim Lesen des Worktree-Status: ',
|
||||
session_worktree_remove_locked_by_stream: 'Entfernen nicht möglich — eine aktive Streaming-Sitzung verwendet diesen Worktree.',
|
||||
session_worktree_remove_locked_by_terminal: 'Entfernen nicht möglich — eine aktive Terminal-Sitzung verwendet diesen Worktree.',
|
||||
session_worktree_remove_unsafe_blocked: 'Löse lokale Änderungen oder nicht gepushte Commits, bevor du diesen Worktree entfernst.',
|
||||
session_worktree_remove_dirty_warning: 'WARNUNG: Dieser Worktree hat nicht festgeschriebene Änderungen, die verloren gehen.',
|
||||
session_worktree_remove_untracked_warning: (count) => `${count} nicht verfolgte Datei(en) werden dauerhaft gelöscht.`,
|
||||
session_worktree_remove_ahead_warning: (ahead) => `${ahead} nicht gepushte Commit(s) gehen verloren.`,
|
||||
session_duplicate: 'Duplicate conversation',
|
||||
session_duplicate_desc: 'Create a copy with the same workspace and model',
|
||||
session_duplicate_failed: 'Duplicate failed: ',
|
||||
@@ -7301,6 +7385,20 @@ const LOCALES = {
|
||||
session_delete_worktree_confirm: (path) => `删除此会话?位于 ${path} 的 worktree 将保留在磁盘上。`,
|
||||
session_deleted: '会话已删除',
|
||||
session_deleted_worktree: '会话已删除。Worktree 仍保留在磁盘上。',
|
||||
session_worktree_remove: '删除 worktree',
|
||||
session_worktree_remove_desc: (path) => `删除位于 ${path} 的 git worktree`,
|
||||
session_worktree_remove_confirm: (path) => `确定从磁盘删除 git worktree?\n\n路径:${path}\n\n整个 worktree 目录将被删除,WebUI 中的会话数据保留。`,
|
||||
session_worktree_remove_not_exists: (path) => `位于 ${path} 的 worktree 在磁盘上已不存在。`,
|
||||
session_worktree_remove_confirm_label: '删除',
|
||||
session_worktree_removed: 'Worktree 已删除。',
|
||||
session_worktree_remove_failed: '删除 worktree 失败:',
|
||||
session_worktree_remove_status_failed: '读取 worktree 状态失败:',
|
||||
session_worktree_remove_locked_by_stream: '无法删除 — 存在活跃的流式会话正在使用此 worktree。',
|
||||
session_worktree_remove_locked_by_terminal: '无法删除 — 存在活跃的终端会话正在使用此 worktree。',
|
||||
session_worktree_remove_unsafe_blocked: '请先处理本地更改或未推送提交,再删除此 worktree。',
|
||||
session_worktree_remove_dirty_warning: '⚠️ 此 worktree 有未提交的更改,将被永久删除。',
|
||||
session_worktree_remove_untracked_warning: (count) => `${count} 个未追踪文件将被永久删除。`,
|
||||
session_worktree_remove_ahead_warning: (ahead) => `${ahead} 个未推送的提交将丢失。`,
|
||||
session_duplicate: '复制会话',
|
||||
session_duplicate_desc: '用相同工作区和模型创建副本',
|
||||
session_duplicate_failed: '复制失败:',
|
||||
@@ -7746,6 +7844,20 @@ const LOCALES = {
|
||||
session_delete_worktree_confirm: (path) => `刪除此對話?位於 ${path} 的 worktree 將保留在磁碟上。`,
|
||||
session_deleted: '對話已刪除',
|
||||
session_deleted_worktree: '對話已刪除。Worktree 仍保留在磁碟上。',
|
||||
session_worktree_remove: '刪除 worktree',
|
||||
session_worktree_remove_desc: (path) => `刪除位於 ${path} 的 git worktree`,
|
||||
session_worktree_remove_confirm: (path) => `確定從磁碟刪除 git worktree?\n\n路徑:${path}\n\n整個 worktree 目錄將被刪除,WebUI 中的工作階段資料保留。`,
|
||||
session_worktree_remove_not_exists: (path) => `位於 ${path} 的 worktree 在磁碟上已不存在。`,
|
||||
session_worktree_remove_confirm_label: '刪除',
|
||||
session_worktree_removed: 'Worktree 已刪除。',
|
||||
session_worktree_remove_failed: '刪除 worktree 失敗:',
|
||||
session_worktree_remove_status_failed: '讀取 worktree 狀態失敗:',
|
||||
session_worktree_remove_locked_by_stream: '無法刪除 — 存在活躍的串流工作階段正在使用此 worktree。',
|
||||
session_worktree_remove_locked_by_terminal: '無法刪除 — 存在活躍的終端機工作階段正在使用此 worktree。',
|
||||
session_worktree_remove_unsafe_blocked: '請先處理本機變更或未推送提交,再刪除此 worktree。',
|
||||
session_worktree_remove_dirty_warning: '⚠️ 此 worktree 有未提交的變更,將被永久刪除。',
|
||||
session_worktree_remove_untracked_warning: (count) => `${count} 個未追蹤檔案將被永久刪除。`,
|
||||
session_worktree_remove_ahead_warning: (ahead) => `${ahead} 個未推送的提交將丟失。`,
|
||||
session_select_mode: '選取',
|
||||
session_select_mode_desc: '選取會話以批次管理',
|
||||
session_select_all: '全選',
|
||||
@@ -8953,6 +9065,20 @@ const LOCALES = {
|
||||
session_delete_worktree_confirm: (path) => `Excluir esta conversa? O worktree em ${path} permanecerá no disco.`,
|
||||
session_deleted: 'Conversa excluída',
|
||||
session_deleted_worktree: 'Conversa excluída. O worktree permanece no disco.',
|
||||
session_worktree_remove: 'Remover worktree',
|
||||
session_worktree_remove_desc: (path) => `Excluir o git worktree em ${path} do disco`,
|
||||
session_worktree_remove_confirm: (path) => `Remover git worktree do disco?\n\nCaminho: ${path}\n\nTodo o diretório do worktree será excluído. Dados da sessão permanecem no WebUI.`,
|
||||
session_worktree_remove_not_exists: (path) => `O worktree em ${path} não existe mais no disco.`,
|
||||
session_worktree_remove_confirm_label: 'Remover',
|
||||
session_worktree_removed: 'Worktree removido.',
|
||||
session_worktree_remove_failed: 'Falha ao remover worktree: ',
|
||||
session_worktree_remove_status_failed: 'Falha ao ler o status do worktree: ',
|
||||
session_worktree_remove_locked_by_stream: 'Não é possível remover — uma sessão de streaming ativa está usando este worktree.',
|
||||
session_worktree_remove_locked_by_terminal: 'Não é possível remover — uma sessão de terminal ativa está usando este worktree.',
|
||||
session_worktree_remove_unsafe_blocked: 'Resolva alterações locais ou commits não enviados antes de remover este worktree.',
|
||||
session_worktree_remove_dirty_warning: 'AVISO: Este worktree tem alterações não confirmadas que serão perdidas.',
|
||||
session_worktree_remove_untracked_warning: (count) => `${count} arquivo(s) não rastreados serão excluídos permanentemente.`,
|
||||
session_worktree_remove_ahead_warning: (ahead) => `${ahead} commit(s) não enviados serão perdidos.`,
|
||||
session_batch_delete_worktree_confirm: 'Excluir {0} conversas? {1} conversa(s) com worktree manterão seus diretórios de worktree no disco.',
|
||||
session_batch_archive_worktree_confirm: 'Arquivar {0} conversas? {1} conversa(s) com worktree manterão seus diretórios de worktree no disco.',
|
||||
session_batch_delete_confirm: 'Excluir {0} conversas?',
|
||||
@@ -9955,6 +10081,20 @@ const LOCALES = {
|
||||
session_delete_worktree_confirm: (path) => `이 대화를 삭제하시겠습니까? ${path}의 worktree는 디스크에 남아 있습니다.`,
|
||||
session_deleted: '대화가 삭제되었습니다',
|
||||
session_deleted_worktree: '대화가 삭제되었습니다. Worktree는 디스크에 남아 있습니다.',
|
||||
session_worktree_remove: '워크트리 삭제',
|
||||
session_worktree_remove_desc: (path) => `${path}의 git worktree를 디스크에서 삭제합니다`,
|
||||
session_worktree_remove_confirm: (path) => `git worktree를 디스크에서 삭제하시겠습니까?\n\n경로: ${path}\n\n전체 worktree 디렉토리가 삭제됩니다. 세션 데이터는 WebUI에 보존됩니다.`,
|
||||
session_worktree_remove_not_exists: (path) => `${path}의 worktree가 디스크에 더 이상 존재하지 않습니다.`,
|
||||
session_worktree_remove_confirm_label: '삭제',
|
||||
session_worktree_removed: '워크트리가 삭제되었습니다.',
|
||||
session_worktree_remove_failed: '워크트리 삭제 실패: ',
|
||||
session_worktree_remove_status_failed: '워크트리 상태 읽기 실패: ',
|
||||
session_worktree_remove_locked_by_stream: '삭제할 수 없습니다 — 활성 스트리밍 세션이 이 worktree를 사용 중입니다.',
|
||||
session_worktree_remove_locked_by_terminal: '삭제할 수 없습니다 — 활성 터미널 세션이 이 worktree를 사용 중입니다.',
|
||||
session_worktree_remove_unsafe_blocked: '이 worktree를 삭제하기 전에 로컬 변경 사항이나 푸시되지 않은 커밋을 정리하세요.',
|
||||
session_worktree_remove_dirty_warning: '경고: 이 worktree에는 커밋되지 않은 변경 사항이 있으며 손실됩니다.',
|
||||
session_worktree_remove_untracked_warning: (count) => `${count}개의 추적되지 않은 파일이 영구적으로 삭제됩니다.`,
|
||||
session_worktree_remove_ahead_warning: (ahead) => `${ahead}개의 푸시되지 않은 커밋이 손실됩니다.`,
|
||||
session_select_mode: '선택',
|
||||
session_select_mode_desc: '일괄 관리할 대화를 선택하세요',
|
||||
session_select_all: '전체 선택',
|
||||
@@ -10981,6 +11121,20 @@ const LOCALES = {
|
||||
session_delete_worktree_desc: 'Supprimez uniquement la conversation WebUI ; garder l\'arbre de travail sur le disque',
|
||||
session_deleted: 'Conversation supprimée',
|
||||
session_deleted_worktree: 'Conversation supprimée. Worktree reste sur le disque.',
|
||||
session_worktree_remove: 'Supprimer le worktree',
|
||||
session_worktree_remove_desc: (path) => `Supprimer le git worktree à ${path} du disque`,
|
||||
session_worktree_remove_confirm: (path) => `Supprimer le git worktree du disque ?\n\nChemin : ${path}\n\nTout le répertoire worktree sera supprimé. Les données de session restent dans WebUI.`,
|
||||
session_worktree_remove_not_exists: (path) => `Le worktree à ${path} n'existe plus sur le disque.`,
|
||||
session_worktree_remove_confirm_label: 'Supprimer',
|
||||
session_worktree_removed: 'Worktree supprimé.',
|
||||
session_worktree_remove_failed: 'Échec de la suppression du worktree : ',
|
||||
session_worktree_remove_status_failed: 'Échec de la lecture du statut du worktree : ',
|
||||
session_worktree_remove_locked_by_stream: 'Impossible de supprimer — une session de streaming active utilise ce worktree.',
|
||||
session_worktree_remove_locked_by_terminal: 'Impossible de supprimer — une session de terminal active utilise ce worktree.',
|
||||
session_worktree_remove_unsafe_blocked: 'Résolvez les modifications locales ou les commits non poussés avant de supprimer ce worktree.',
|
||||
session_worktree_remove_dirty_warning: 'ATTENTION : Ce worktree a des modifications non validées qui seront perdues.',
|
||||
session_worktree_remove_untracked_warning: (count) => `${count} fichier(s) non suivi(s) seront définitivement supprimés.`,
|
||||
session_worktree_remove_ahead_warning: (ahead) => `${ahead} commit(s) non poussé(s) seront perdus.`,
|
||||
session_select_mode: 'Sélectionner',
|
||||
session_select_mode_desc: 'Sélectionnez les conversations à gérer par lots',
|
||||
session_select_all: 'Tout sélectionner',
|
||||
|
||||
@@ -1663,6 +1663,18 @@ function _openSessionActionMenu(session, anchorEl){
|
||||
));
|
||||
}
|
||||
if(!isExternalSession){
|
||||
if(session.worktree_path){
|
||||
menu.appendChild(_buildSessionAction(
|
||||
t('session_worktree_remove'),
|
||||
t('session_worktree_remove_desc', session.worktree_path),
|
||||
ICONS.trash,
|
||||
async()=>{
|
||||
closeSessionActionMenu();
|
||||
await removeWorktree(session);
|
||||
},
|
||||
'danger'
|
||||
));
|
||||
}
|
||||
menu.appendChild(_buildSessionAction(
|
||||
t('session_delete'),
|
||||
_sessionDeleteDescription(session),
|
||||
@@ -3149,6 +3161,81 @@ if(typeof window!=='undefined'){
|
||||
});
|
||||
}
|
||||
|
||||
async function removeWorktree(session){
|
||||
// Fetch status first
|
||||
let status=null;
|
||||
try{
|
||||
const statusResp=await api('/api/session/worktree/status?session_id='+encodeURIComponent(session.session_id));
|
||||
status=statusResp.status;
|
||||
}catch(e){
|
||||
showToast(t('session_worktree_remove_status_failed')+e.message,0,'error');
|
||||
return;
|
||||
}
|
||||
if(!status){
|
||||
showToast(t('session_worktree_remove_status_failed'),0,'error');
|
||||
return;
|
||||
}
|
||||
// Build confirm message
|
||||
let details='';
|
||||
if(!status.exists){
|
||||
details=t('session_worktree_remove_not_exists',status.path);
|
||||
}else{
|
||||
details=t('session_worktree_remove_confirm',status.path);
|
||||
if(status.locked_by_stream){
|
||||
showToast(t('session_worktree_remove_locked_by_stream'),0,'error');
|
||||
return;
|
||||
}
|
||||
if(status.locked_by_terminal){
|
||||
showToast(t('session_worktree_remove_locked_by_terminal'),0,'error');
|
||||
return;
|
||||
}
|
||||
if(status.dirty){
|
||||
details+='\n\n'+t('session_worktree_remove_dirty_warning');
|
||||
}
|
||||
if(status.untracked_count>0){
|
||||
details+='\n'+t('session_worktree_remove_untracked_warning',status.untracked_count);
|
||||
}
|
||||
if(status.ahead_behind&&status.ahead_behind.ahead>0){
|
||||
details+='\n'+t('session_worktree_remove_ahead_warning',status.ahead_behind.ahead);
|
||||
}
|
||||
if(status.dirty||status.untracked_count>0||(status.ahead_behind&&status.ahead_behind.ahead>0)){
|
||||
showToast(t('session_worktree_remove_failed')+t('session_worktree_remove_unsafe_blocked'),0,'error');
|
||||
await showConfirmDialog({
|
||||
message:details,
|
||||
confirmLabel:t('dialog_confirm_btn'),
|
||||
danger:true,
|
||||
focusCancel:true
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
const ok=await showConfirmDialog({
|
||||
message:details,
|
||||
confirmLabel:t('session_worktree_remove_confirm_label'),
|
||||
danger:true
|
||||
});
|
||||
if(!ok)return;
|
||||
try{
|
||||
const result=await api('/api/session/worktree/remove',{
|
||||
method:'POST',
|
||||
body:JSON.stringify({session_id:session.session_id, force:false})
|
||||
});
|
||||
const warn=result.warnings&&result.warnings.length?(' '+result.warnings.join(' ')):'';
|
||||
showToast(t('session_worktree_removed')+warn);
|
||||
// Clear the worktree_path from cached session so menu doesn't show stale remove action
|
||||
if(session.worktree_path){
|
||||
session.worktree_path=null;
|
||||
}
|
||||
// Re-render the list if this is the active session
|
||||
if(S.session&&S.session.session_id===session.session_id&&S.session.worktree_path){
|
||||
S.session.worktree_path=null;
|
||||
}
|
||||
await renderSessionList();
|
||||
}catch(e){
|
||||
showToast(t('session_worktree_remove_failed')+e.message,0,'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSession(sid){
|
||||
const session=_sessionSnapshotById(sid);
|
||||
const ok=await showConfirmDialog({
|
||||
|
||||
@@ -68,3 +68,15 @@ def test_worktree_archive_delete_api_responses_are_explicit():
|
||||
assert '"worktree_retained": True' in src
|
||||
assert '{"ok": True, **worktree_retained}' in src
|
||||
assert '{"ok": True, "session": s.compact(), **_worktree_retained_payload(s)}' in src
|
||||
|
||||
|
||||
def test_remove_worktree_ui_does_not_force_unsafe_status_by_default():
|
||||
src = read("static/sessions.js")
|
||||
i18n = read("static/i18n.js")
|
||||
assert "async function removeWorktree(session)" in src
|
||||
assert "status.dirty||status.untracked_count>0||(status.ahead_behind&&status.ahead_behind.ahead>0)" in src
|
||||
assert "session_worktree_remove_unsafe_blocked" in src
|
||||
assert "session_worktree_remove_unsafe_blocked" in i18n
|
||||
assert "Resolve local changes or unpushed commits before removing this worktree." in i18n
|
||||
assert "JSON.stringify({session_id:session.session_id, force:false})" in src
|
||||
assert "const force=(status.dirty||status.untracked_count>0)" not in src
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Tests for the worktree remove functionality (Issue #2057 Slice 2)."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import api.models as models
|
||||
import api.routes as routes
|
||||
import api.worktrees as worktrees
|
||||
|
||||
|
||||
def _capture_post(monkeypatch, body):
|
||||
captured = {}
|
||||
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
|
||||
monkeypatch.setattr(routes, "read_body", lambda handler: body)
|
||||
# Monkeypatch both helpers.j and routes.j — bad() lives in helpers but calls the module-global j
|
||||
import api.helpers as helpers
|
||||
def _fake_j(handler, payload, status=200, extra_headers=None):
|
||||
captured.update(payload=payload, status=status)
|
||||
return True
|
||||
monkeypatch.setattr(routes, "j", _fake_j)
|
||||
monkeypatch.setattr(helpers, "j", _fake_j)
|
||||
return captured
|
||||
|
||||
|
||||
def _isolate_session_store(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")
|
||||
monkeypatch.setattr(routes, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(routes, "SESSION_INDEX_FILE", session_dir / "_index.json")
|
||||
models.SESSIONS.clear()
|
||||
return session_dir
|
||||
|
||||
|
||||
def _make_minimal_git_repo(tmp_path):
|
||||
import subprocess
|
||||
main = tmp_path / "main"
|
||||
main.mkdir()
|
||||
subprocess.run(["git", "init", "-b", "main", str(main)], check=True, capture_output=True)
|
||||
subprocess.run(["git", "-C", str(main), "config", "user.email", "test@test.test"], check=True, capture_output=True)
|
||||
subprocess.run(["git", "-C", str(main), "config", "user.name", "Test"], check=True, capture_output=True)
|
||||
(main / "file.txt").write_text("content")
|
||||
subprocess.run(["git", "-C", str(main), "add", "file.txt"], check=True, capture_output=True)
|
||||
subprocess.run(["git", "-C", str(main), "commit", "-m", "init"], check=True, capture_output=True)
|
||||
return main
|
||||
|
||||
|
||||
# ── Function-level tests ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_remove_clean_worktree_succeeds(tmp_path):
|
||||
import subprocess
|
||||
from api.models import Session
|
||||
|
||||
main = _make_minimal_git_repo(tmp_path)
|
||||
wt_path = tmp_path / "wt_clean"
|
||||
subprocess.run(
|
||||
["git", "-C", str(main), "worktree", "add", str(wt_path), "-b", "hermes/testclean"],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
assert wt_path.exists()
|
||||
|
||||
s = Session(
|
||||
session_id="testclean",
|
||||
title="Clean",
|
||||
workspace=str(wt_path),
|
||||
worktree_path=str(wt_path),
|
||||
worktree_branch="hermes/testclean",
|
||||
worktree_repo_root=str(main),
|
||||
)
|
||||
|
||||
result = worktrees.remove_worktree_for_session(s, force=False)
|
||||
assert result["ok"] is True
|
||||
assert result["removed_path"] == str(wt_path.resolve())
|
||||
assert not wt_path.exists()
|
||||
|
||||
|
||||
def test_remove_clean_worktree_does_not_force(tmp_path, monkeypatch):
|
||||
from api.models import Session
|
||||
|
||||
worktree_path = tmp_path / "wt_clean"
|
||||
worktree_path.mkdir()
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
s = Session(
|
||||
session_id="testcleanforce",
|
||||
title="Clean",
|
||||
workspace=str(worktree_path),
|
||||
worktree_path=str(worktree_path),
|
||||
worktree_branch="hermes/testcleanforce",
|
||||
worktree_repo_root=str(repo_root),
|
||||
)
|
||||
monkeypatch.setattr(worktrees, "worktree_status_for_session", lambda session: {
|
||||
"exists": True,
|
||||
"dirty": False,
|
||||
"untracked_count": 0,
|
||||
"ahead_behind": {"ahead": 0, "behind": 0, "available": False, "upstream": None},
|
||||
"locked_by_stream": False,
|
||||
"locked_by_terminal": False,
|
||||
})
|
||||
calls = []
|
||||
|
||||
def fake_run_git(args, cwd, timeout=2):
|
||||
calls.append(args)
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(worktrees, "_run_git", fake_run_git)
|
||||
|
||||
result = worktrees.remove_worktree_for_session(s, force=False)
|
||||
assert result["ok"] is True
|
||||
assert calls[0] == ["worktree", "remove", str(worktree_path.resolve())]
|
||||
|
||||
|
||||
def test_remove_dirty_worktree_without_force_is_rejected(tmp_path, monkeypatch):
|
||||
from api.models import Session
|
||||
|
||||
worktree_path = tmp_path / "wt_dirty"
|
||||
worktree_path.mkdir()
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
s = Session(
|
||||
session_id="testdirty",
|
||||
title="Dirty",
|
||||
workspace=str(worktree_path),
|
||||
worktree_path=str(worktree_path),
|
||||
worktree_branch="hermes/testdirty",
|
||||
worktree_repo_root=str(repo_root),
|
||||
)
|
||||
monkeypatch.setattr(worktrees, "worktree_status_for_session", lambda session: {
|
||||
"exists": True,
|
||||
"dirty": True,
|
||||
"untracked_count": 0,
|
||||
"ahead_behind": {"ahead": 0, "behind": 0, "available": False, "upstream": None},
|
||||
"locked_by_stream": False,
|
||||
"locked_by_terminal": False,
|
||||
})
|
||||
monkeypatch.setattr(worktrees, "_run_git", lambda *args, **kwargs: pytest.fail("git remove should not run"))
|
||||
|
||||
with pytest.raises(ValueError, match="uncommitted changes"):
|
||||
worktrees.remove_worktree_for_session(s, force=False)
|
||||
|
||||
|
||||
def test_remove_untracked_worktree_without_force_is_rejected(tmp_path, monkeypatch):
|
||||
from api.models import Session
|
||||
|
||||
worktree_path = tmp_path / "wt_untracked"
|
||||
worktree_path.mkdir()
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
s = Session(
|
||||
session_id="testuntracked",
|
||||
title="Untracked",
|
||||
workspace=str(worktree_path),
|
||||
worktree_path=str(worktree_path),
|
||||
worktree_branch="hermes/testuntracked",
|
||||
worktree_repo_root=str(repo_root),
|
||||
)
|
||||
monkeypatch.setattr(worktrees, "worktree_status_for_session", lambda session: {
|
||||
"exists": True,
|
||||
"dirty": False,
|
||||
"untracked_count": 2,
|
||||
"ahead_behind": {"ahead": 0, "behind": 0, "available": False, "upstream": None},
|
||||
"locked_by_stream": False,
|
||||
"locked_by_terminal": False,
|
||||
})
|
||||
monkeypatch.setattr(worktrees, "_run_git", lambda *args, **kwargs: pytest.fail("git remove should not run"))
|
||||
|
||||
with pytest.raises(ValueError, match="untracked"):
|
||||
worktrees.remove_worktree_for_session(s, force=False)
|
||||
|
||||
|
||||
def test_remove_ahead_worktree_without_force_is_rejected(tmp_path, monkeypatch):
|
||||
from api.models import Session
|
||||
|
||||
worktree_path = tmp_path / "wt_ahead"
|
||||
worktree_path.mkdir()
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
s = Session(
|
||||
session_id="testahead",
|
||||
title="Ahead",
|
||||
workspace=str(worktree_path),
|
||||
worktree_path=str(worktree_path),
|
||||
worktree_branch="hermes/testahead",
|
||||
worktree_repo_root=str(repo_root),
|
||||
)
|
||||
monkeypatch.setattr(worktrees, "worktree_status_for_session", lambda session: {
|
||||
"exists": True,
|
||||
"dirty": False,
|
||||
"untracked_count": 0,
|
||||
"ahead_behind": {"ahead": 1, "behind": 0, "available": True, "upstream": "origin/main"},
|
||||
"locked_by_stream": False,
|
||||
"locked_by_terminal": False,
|
||||
})
|
||||
monkeypatch.setattr(worktrees, "_run_git", lambda *args, **kwargs: pytest.fail("git remove should not run"))
|
||||
|
||||
with pytest.raises(ValueError, match="unpushed"):
|
||||
worktrees.remove_worktree_for_session(s, force=False)
|
||||
|
||||
|
||||
def test_remove_force_warns_and_uses_git_force(tmp_path, monkeypatch):
|
||||
from api.models import Session
|
||||
|
||||
worktree_path = tmp_path / "wt_force"
|
||||
worktree_path.mkdir()
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
s = Session(
|
||||
session_id="testforce",
|
||||
title="Force",
|
||||
workspace=str(worktree_path),
|
||||
worktree_path=str(worktree_path),
|
||||
worktree_branch="hermes/testforce",
|
||||
worktree_repo_root=str(repo_root),
|
||||
)
|
||||
monkeypatch.setattr(worktrees, "worktree_status_for_session", lambda session: {
|
||||
"exists": True,
|
||||
"dirty": True,
|
||||
"untracked_count": 3,
|
||||
"ahead_behind": {"ahead": 2, "behind": 0, "available": True, "upstream": "origin/main"},
|
||||
"locked_by_stream": False,
|
||||
"locked_by_terminal": False,
|
||||
})
|
||||
calls = []
|
||||
|
||||
def fake_run_git(args, cwd, timeout=2):
|
||||
calls.append(args)
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(worktrees, "_run_git", fake_run_git)
|
||||
|
||||
result = worktrees.remove_worktree_for_session(s, force=True)
|
||||
assert result["ok"] is True
|
||||
assert calls[0] == ["worktree", "remove", "--force", str(worktree_path.resolve())]
|
||||
assert "untracked file" in " ".join(result["warnings"])
|
||||
assert "unpushed commit" in " ".join(result["warnings"])
|
||||
|
||||
|
||||
def test_remove_worktree_not_exists(tmp_path):
|
||||
from api.models import Session
|
||||
|
||||
s = Session(
|
||||
session_id="testgone",
|
||||
title="Gone",
|
||||
workspace=str(tmp_path / "gone"),
|
||||
worktree_path=str(tmp_path / "gone"),
|
||||
worktree_branch="hermes/gone",
|
||||
worktree_repo_root=str(tmp_path / "repo"),
|
||||
)
|
||||
|
||||
result = worktrees.remove_worktree_for_session(s, force=False)
|
||||
assert result["ok"] is True
|
||||
assert len(result.get("warnings", [])) >= 1
|
||||
|
||||
|
||||
def test_remove_worktree_no_path_raises(tmp_path):
|
||||
from api.models import Session
|
||||
|
||||
s = Session(
|
||||
session_id="testnowt",
|
||||
title="No worktree",
|
||||
workspace=str(tmp_path),
|
||||
)
|
||||
|
||||
try:
|
||||
worktrees.remove_worktree_for_session(s, force=False)
|
||||
assert False, "should have raised ValueError"
|
||||
except ValueError as e:
|
||||
assert "not worktree-backed" in str(e)
|
||||
|
||||
|
||||
# ── Route-level tests ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_remove_worktree_route_succeeds(tmp_path, monkeypatch):
|
||||
import subprocess
|
||||
from api.models import Session
|
||||
|
||||
main = _make_minimal_git_repo(tmp_path)
|
||||
wt_path = tmp_path / "wt_route"
|
||||
subprocess.run(
|
||||
["git", "-C", str(main), "worktree", "add", str(wt_path), "-b", "hermes/testroute"],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
|
||||
_isolate_session_store(tmp_path, monkeypatch)
|
||||
|
||||
s = Session(
|
||||
session_id="testroute1",
|
||||
title="Route",
|
||||
workspace=str(wt_path),
|
||||
worktree_path=str(wt_path),
|
||||
worktree_branch="hermes/testroute",
|
||||
worktree_repo_root=str(main),
|
||||
)
|
||||
s.save()
|
||||
|
||||
body = {"session_id": "testroute1"}
|
||||
captured = _capture_post(monkeypatch, body)
|
||||
|
||||
assert routes.handle_post(object(), SimpleNamespace(path="/api/session/worktree/remove")) is True
|
||||
assert captured["status"] == 200
|
||||
assert captured["payload"]["ok"] is True
|
||||
assert captured["payload"]["removed_path"] == str(wt_path.resolve())
|
||||
assert not wt_path.exists()
|
||||
|
||||
|
||||
def test_remove_missing_session_returns_404(tmp_path, monkeypatch):
|
||||
from api.models import Session
|
||||
|
||||
_isolate_session_store(tmp_path, monkeypatch)
|
||||
|
||||
s = Session(
|
||||
session_id="someother",
|
||||
title="Other",
|
||||
workspace=str(tmp_path),
|
||||
)
|
||||
s.save()
|
||||
|
||||
body = {"session_id": "nonexistent"}
|
||||
captured = _capture_post(monkeypatch, body)
|
||||
|
||||
routes.handle_post(object(), SimpleNamespace(path="/api/session/worktree/remove"))
|
||||
assert captured["status"] == 404
|
||||
assert "not found" in captured["payload"].get("error", "").lower()
|
||||
|
||||
|
||||
def test_post_router_does_not_expose_read_only_worktree_or_compress_status():
|
||||
src = Path("api/routes.py").read_text(encoding="utf-8")
|
||||
post_body = src[src.index("def handle_post"):src.index('if parsed.path == "/api/session/worktree/remove"')]
|
||||
assert '"/api/session/worktree/status"' not in post_body
|
||||
assert '"/api/session/compress/status"' not in post_body
|
||||
Reference in New Issue
Block a user