Merge pull request #2156 into stage-346

Issue #2057 Slice 2: Add guarded worktree remove action
This commit is contained in:
Hermes Agent
2026-05-13 06:56:25 +00:00
9 changed files with 710 additions and 2 deletions
+3 -2
View File
@@ -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.
+23
View File
@@ -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", "")
+96
View File
@@ -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
View File
@@ -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',
+87
View File
@@ -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
+335
View File
@@ -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