diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bc8b3630..1d8f5a71 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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. diff --git a/api/routes.py b/api/routes.py index 0c9a492f..df290280 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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", "") diff --git a/api/worktrees.py b/api/worktrees.py index e71fea6c..42424ee5 100644 --- a/api/worktrees.py +++ b/api/worktrees.py @@ -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*. diff --git a/docs/pr-media/2156/worktree-remove-confirm.png b/docs/pr-media/2156/worktree-remove-confirm.png new file mode 100644 index 00000000..ebbd57ad Binary files /dev/null and b/docs/pr-media/2156/worktree-remove-confirm.png differ diff --git a/docs/pr-media/2156/worktree-remove-menu.png b/docs/pr-media/2156/worktree-remove-menu.png new file mode 100644 index 00000000..0865ef6f Binary files /dev/null and b/docs/pr-media/2156/worktree-remove-menu.png differ diff --git a/static/i18n.js b/static/i18n.js index 7def6da5..b0a28ade 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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', diff --git a/static/sessions.js b/static/sessions.js index 341d53fb..22d4799a 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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({ diff --git a/tests/test_issue2057_worktree_ui_static.py b/tests/test_issue2057_worktree_ui_static.py index b9233280..3b99921c 100644 --- a/tests/test_issue2057_worktree_ui_static.py +++ b/tests/test_issue2057_worktree_ui_static.py @@ -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 diff --git a/tests/test_worktree_remove.py b/tests/test_worktree_remove.py new file mode 100644 index 00000000..0c658235 --- /dev/null +++ b/tests/test_worktree_remove.py @@ -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