diff --git a/README.md b/README.md index 8039fd0..1880058 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,27 @@ hqt | `r` | Rename session | | `s` | Stop session | +## Worktree-isolated sessions + +The New Session dialog (`n`) has an **Isolate in worktree** checkbox. When the +selected project is a git repository, checking it runs the session in a +dedicated [git worktree](https://git-scm.com/docs/git-worktree) instead of the +project root, so concurrent sessions don't step on each other's working tree. + +- The worktree lives at `/.worktrees/` and is automatically + added to the repo's `.git/info/exclude` so it never shows up as untracked. +- The branch is created off the current `HEAD`. If you leave the branch field + blank it defaults to a slugified form of the session nickname. +- For non-git projects the checkbox is disabled. + +Deleting a worktree session (`d`) opens a confirmation dialog with an **Also +remove worktree** option. As a safety check, if the worktree has uncommitted +changes or unmerged commits a warning is shown and the removal box is left +unchecked — checking it then forces removal and discards that work. + +Known limitation: submodules are not initialized in freshly created worktrees; +run `git submodule update --init` inside the worktree if your project needs them. + ## Architecture hqt uses a layered architecture: a Click CLI bootstraps into a Textual TUI, which drives service classes (ProjectService, SessionService) backed by SQLite via SQLAlchemy. Sessions are spawned as tmux windows through TmuxManager, with harness-specific configuration provided by pluggable HarnessConfigurator implementations discovered at runtime. diff --git a/src/hqt/tui/app.py b/src/hqt/tui/app.py index 6252098..163aaf8 100644 --- a/src/hqt/tui/app.py +++ b/src/hqt/tui/app.py @@ -1,5 +1,6 @@ import logging from collections.abc import Coroutine +from pathlib import Path from typing import Any from sqlalchemy.orm import sessionmaker @@ -10,6 +11,7 @@ from textual.theme import Theme from textual.widgets import Footer from hqt.config import get_settings +from hqt.git import worktree from hqt.db.engine import ensure_db, get_engine, get_session_factory from hqt.errors import ServiceError from hqt.harnesses.registry import discover_harnesses, ensure_harnesses_in_db @@ -17,6 +19,7 @@ from hqt.projects.service import ProjectService from hqt.sessions.service import SessionService from hqt.tmux.manager import TmuxManager from hqt.tmux.runner import TmuxRunner +from hqt.tui.screens.confirm_delete import ConfirmDeleteScreen from hqt.tui.screens.project_form import ProjectFormScreen from hqt.tui.screens.new_session import NewSessionScreen from hqt.tui.screens.rename_session import RenameSessionScreen @@ -245,14 +248,25 @@ class HqtApp(App): if not harness_names: self.notify("No harnesses found", severity="error") return + project = self._projects().get(project_id) + if project is None: + self.notify("Project not found", severity="error") + return + project_path = project.path - def on_dismiss(result: tuple[str, str, str | None] | None) -> None: + def on_dismiss( + result: tuple[str, str, str | None, str | None] | None, + ) -> None: if result: - harness_name, nickname, model = result + harness_name, nickname, model, worktree_branch = result async def _do() -> None: create_result = await self._sessions().create_session( - project_id, harness_name, nickname, model + project_id, + harness_name, + nickname, + model, + worktree_branch=worktree_branch, ) if not create_result.spawn_ok: first_line = next( @@ -271,7 +285,13 @@ class HqtApp(App): self._run_service_worker(_do()) - self.push_screen(NewSessionScreen(harness_names), on_dismiss) + async def _open() -> None: + # is_git_repo is async; resolve it before pushing so the dialog can + # disable the worktree control for non-repo projects. + is_git_repo = await worktree.is_git_repo(Path(project_path)) + self.push_screen(NewSessionScreen(harness_names, is_git_repo), on_dismiss) + + self.run_worker(_open()) def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: # `Binding("enter", "attach_session", priority=True)` is app-global, so @@ -297,13 +317,55 @@ class HqtApp(App): def action_delete_session(self) -> None: sid = self.query_one(SessionList).get_selected_session_id() - if sid: + if not sid: + return + sess = self._sessions().get_session(sid) + if sess is None: + self.notify("Session not found", severity="error") + return + + # Plain (non-worktree) sessions delete immediately, no confirmation. + if sess.worktree_path is None: async def _do() -> None: await self._sessions().delete_session(sid) await self._refresh_sessions() self._run_service_worker(_do()) + return + + session_label = sess.nickname or sess.tmux_session_name + + async def _confirm() -> None: + state = await self._sessions().worktree_state_for(sid) + parts: list[str] = [] + if state and state.dirty: + parts.append("uncommitted changes") + if state and state.unique_commits > 0: + parts.append(f"{state.unique_commits} unmerged commit(s)") + warning = ( + " and ".join(parts).capitalize() + " — work will be lost" + if parts + else None + ) + + def on_dismiss(result: tuple[bool, bool] | None) -> None: + if result is None: + return + _confirmed, remove = result + force = remove and warning is not None + + async def _do() -> None: + await self._sessions().delete_session( + sid, remove_worktree=remove, force=force + ) + await self._refresh_sessions() + + self._run_service_worker(_do()) + + self.push_screen(ConfirmDeleteScreen(session_label, warning), on_dismiss) + + self._run_service_worker(_confirm()) def action_rename_session(self) -> None: sid = self.query_one(SessionList).get_selected_session_id() diff --git a/src/hqt/tui/screens/confirm_delete.py b/src/hqt/tui/screens/confirm_delete.py new file mode 100644 index 0000000..028ae5e --- /dev/null +++ b/src/hqt/tui/screens/confirm_delete.py @@ -0,0 +1,41 @@ +from textual.app import ComposeResult +from textual.containers import Horizontal, Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, Checkbox, Label + + +class ConfirmDeleteScreen(ModalScreen[tuple[bool, bool] | None]): + """Confirm deletion of a worktree session. + + Dismisses with ``(True, remove_worktree)`` on Delete, or ``None`` on Cancel. + Mirrors RenameSessionScreen's structure so it reuses the shared + ``#project-form-dialog`` styling. The "Also remove worktree" checkbox is + pre-checked only when there is no warning (clean worktree); when a warning is + present the caller treats a checked box as a forced removal. + """ + + def __init__(self, session_label: str, warning: str | None = None) -> None: + self._session_label = session_label + self._warning = warning + super().__init__() + + def compose(self) -> ComposeResult: + with Vertical(id="project-form-dialog"): + yield Label(f"Delete session {self._session_label}?") + if self._warning is not None: + yield Label(self._warning, id="delete-warning") + yield Checkbox( + "Also remove worktree", + value=(self._warning is None), + id="remove-worktree-checkbox", + ) + with Horizontal(classes="dialog-actions"): + yield Button("Delete", variant="error", id="ok-btn") + yield Button("Cancel", id="cancel-btn") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "ok-btn": + checkbox = self.query_one("#remove-worktree-checkbox", Checkbox) + self.dismiss((True, checkbox.value)) + else: + self.dismiss(None) diff --git a/src/hqt/tui/screens/new_session.py b/src/hqt/tui/screens/new_session.py index 03eb462..4933cf9 100644 --- a/src/hqt/tui/screens/new_session.py +++ b/src/hqt/tui/screens/new_session.py @@ -1,12 +1,15 @@ from textual.app import ComposeResult from textual.containers import Horizontal, Vertical from textual.screen import ModalScreen -from textual.widgets import Button, Input, Label, Select +from textual.widgets import Button, Checkbox, Input, Label, Select + +from hqt.git import worktree -class NewSessionScreen(ModalScreen[tuple[str, str, str | None] | None]): - def __init__(self, harness_names: list[str]) -> None: +class NewSessionScreen(ModalScreen[tuple[str, str, str | None, str | None] | None]): + def __init__(self, harness_names: list[str], is_git_repo: bool) -> None: self.harness_names = harness_names + self._is_git_repo = is_git_repo super().__init__() def compose(self) -> ComposeResult: @@ -26,10 +29,37 @@ class NewSessionScreen(ModalScreen[tuple[str, str, str | None] | None]): yield Input(placeholder="session nickname", id="nickname-input") yield Label("Model (optional):") yield Input(placeholder="model name", id="model-input") + checkbox_label = ( + "Isolate in worktree" + if self._is_git_repo + else "Isolate in worktree (not a git repo)" + ) + yield Checkbox( + checkbox_label, + id="worktree-checkbox", + disabled=not self._is_git_repo, + ) + # Hidden until the worktree checkbox is checked. + branch_input = Input(placeholder="branch name", id="branch-input") + branch_input.display = False + yield branch_input with Horizontal(classes="dialog-actions"): yield Button("OK", variant="primary", id="ok-btn") yield Button("Cancel", id="cancel-btn") + def on_checkbox_changed(self, event: Checkbox.Changed) -> None: + if event.checkbox.id != "worktree-checkbox": + return + branch_input = self.query_one("#branch-input", Input) + if event.value: + branch_input.display = True + # Prefill from the nickname slug, but never clobber user-typed text. + if not branch_input.value: + nickname = self.query_one("#nickname-input", Input).value + branch_input.value = worktree.slugify(nickname) + else: + branch_input.display = False + def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "ok-btn": self._submit() @@ -45,7 +75,18 @@ class NewSessionScreen(ModalScreen[tuple[str, str, str | None] | None]): harness = self.query_one("#harness-select", Select).value nickname = self.query_one("#nickname-input", Input).value model = self.query_one("#model-input", Input).value or None + + worktree_branch: str | None = None + if self.query_one("#worktree-checkbox", Checkbox).value: + branch_input = self.query_one("#branch-input", Input) + worktree_branch = branch_input.value.strip() or worktree.slugify(nickname) + if not worktree_branch: + # Nothing to derive a branch from — keep the dialog open and let + # the user supply a branch name. + branch_input.focus() + return + if harness and harness != Select.BLANK: - self.dismiss((str(harness), nickname, model)) + self.dismiss((str(harness), nickname, model, worktree_branch)) else: self.dismiss(None) diff --git a/src/hqt/tui/widgets/session_list.py b/src/hqt/tui/widgets/session_list.py index 05f1789..445a833 100644 --- a/src/hqt/tui/widgets/session_list.py +++ b/src/hqt/tui/widgets/session_list.py @@ -29,10 +29,19 @@ _STATUS_COLORS: dict[str, str] = { def format_session_text( - nickname: str, harness_name: str, status_text: str, symbol: str + nickname: str, + harness_name: str, + status_text: str, + symbol: str, + worktree_branch: str | None = None, ) -> str: color = _STATUS_COLORS.get(status_text, "#c6d0f5") # default: Text - label = escape(f"{nickname} [{harness_name}]") + name = nickname + # Mark worktree sessions with their branch. The nickname defaults to the + # branch at creation, so only append the marker when it adds information. + if worktree_branch and nickname != worktree_branch: + name = f"{nickname} ⎇ {worktree_branch}" + label = escape(f"{name} [{harness_name}]") return f"[{color}]{symbol}[/] {label} {status_text}" @@ -52,7 +61,11 @@ class SessionList(Widget, can_focus=False): status_text = getattr(info, "status", "dead" if not info.alive else "idle") symbol = status_symbol(status_text, alive=info.alive) text = format_session_text( - s.nickname or s.tmux_session_name, s.harness.name, status_text, symbol + s.nickname or s.tmux_session_name, + s.harness.name, + status_text, + symbol, + getattr(s, "worktree_branch", None), ) new_items.append((f"sess-{s.id}", text, s.id)) diff --git a/tests/test_tui.py b/tests/test_tui.py index eaa972f..460de7f 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -291,7 +291,9 @@ async def test_new_session_on_dismiss_sequential_create_then_refresh(): call_order: list[str] = [] - async def fake_create(project_id, harness_name, nickname, model): + async def fake_create( + project_id, harness_name, nickname, model, worktree_branch=None + ): # Brief yield so that, if two separate workers were used, the list # worker would be able to start and record "list" before this # completes — exposing the regression. @@ -318,10 +320,11 @@ async def test_new_session_on_dismiss_sequential_create_then_refresh(): with patch("hqt.tui.app.discover_harnesses", return_value={"claude": object()}): app.action_new_session() + await app.workers.wait_for_complete() await pilot.pause() # The NewSessionScreen is now on top; dismiss it with a valid result tuple. - app.screen.dismiss(("claude", "mynick", None)) + app.screen.dismiss(("claude", "mynick", None, None)) await pilot.pause() # Wait for the worker spawned by on_dismiss to finish deterministically. @@ -390,9 +393,10 @@ async def test_new_session_on_dismiss_notifies_on_spawn_failure(): with patch("hqt.tui.app.discover_harnesses", return_value={"claude": object()}): with patch.object(app, "notify", side_effect=capture_notify): app.action_new_session() + await app.workers.wait_for_complete() await pilot.pause() - app.screen.dismiss(("claude", "mynick", None)) + app.screen.dismiss(("claude", "mynick", None, None)) await pilot.pause() await app.workers.wait_for_complete() await pilot.pause() @@ -460,9 +464,10 @@ async def test_new_session_on_dismiss_no_notify_on_success(): with patch("hqt.tui.app.discover_harnesses", return_value={"claude": object()}): with patch.object(app, "notify", side_effect=capture_notify): app.action_new_session() + await app.workers.wait_for_complete() await pilot.pause() - app.screen.dismiss(("claude", "mynick", None)) + app.screen.dismiss(("claude", "mynick", None, None)) await pilot.pause() await app.workers.wait_for_complete() await pilot.pause() @@ -493,7 +498,7 @@ async def test_attach_enter_binding_disabled_in_modal(): # On the main screen the attach binding is live. assert app.check_action("attach_session", ()) is True - app.push_screen(NewSessionScreen(["claude", "codex"])) + app.push_screen(NewSessionScreen(["claude", "codex"], is_git_repo=True)) await pilot.pause() # With a modal on top, the binding is disabled so Enter reaches the modal. @@ -515,7 +520,7 @@ async def test_new_session_enter_selects_highlighted_harness(): from hqt.tui.screens.new_session import NewSessionScreen from textual.widgets import Select - app.push_screen(NewSessionScreen(["claude", "codex", "kiro"])) + app.push_screen(NewSessionScreen(["claude", "codex", "kiro"], is_git_repo=True)) await pilot.pause() sel = app.screen.query_one("#harness-select", Select) sel.focus() @@ -540,7 +545,7 @@ async def test_new_session_enter_without_navigation_selects_first_harness(): from hqt.tui.screens.new_session import NewSessionScreen from textual.widgets import Select - app.push_screen(NewSessionScreen(["claude", "codex", "kiro"])) + app.push_screen(NewSessionScreen(["claude", "codex", "kiro"], is_git_repo=True)) await pilot.pause() sel = app.screen.query_one("#harness-select", Select) sel.focus() @@ -565,7 +570,9 @@ async def test_new_session_enter_in_input_submits_dialog(): from textual.widgets import Input results: list = [] - app.push_screen(NewSessionScreen(["claude", "codex"]), results.append) + app.push_screen( + NewSessionScreen(["claude", "codex"], is_git_repo=True), results.append + ) await pilot.pause() nick = app.screen.query_one("#nickname-input", Input) @@ -575,7 +582,7 @@ async def test_new_session_enter_in_input_submits_dialog(): await pilot.press("enter") await pilot.pause() - assert results == [("claude", "mywork", None)], f"got {results}" + assert results == [("claude", "mywork", None, None)], f"got {results}" # --------------------------------------------------------------------------- @@ -1221,3 +1228,246 @@ async def test_session_list_jk_navigation(): await pilot.press("k") await pilot.pause() assert sl.get_selected_session_id() == session_ids[0] + + +# --------------------------------------------------------------------------- +# Worktree session UI: NewSessionScreen worktree controls +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_new_session_worktree_checkbox_disabled_when_not_git_repo(): + """The worktree checkbox is disabled for non-git-repo projects.""" + app = HqtApp() + async with app.run_test(size=(120, 40)) as pilot: + from hqt.tui.screens.new_session import NewSessionScreen + from textual.widgets import Checkbox + + app.push_screen(NewSessionScreen(["claude"], is_git_repo=False)) + await pilot.pause() + cb = app.screen.query_one("#worktree-checkbox", Checkbox) + assert cb.disabled is True + assert "not a git repo" in str(cb.label) + + +@pytest.mark.asyncio +async def test_new_session_worktree_unchecked_returns_none_branch(): + """With the checkbox unchecked, worktree_branch is None.""" + app = HqtApp() + async with app.run_test(size=(120, 40)) as pilot: + from hqt.tui.screens.new_session import NewSessionScreen + from textual.widgets import Button, Input + + results: list = [] + app.push_screen(NewSessionScreen(["claude"], is_git_repo=True), results.append) + await pilot.pause() + app.screen.query_one("#nickname-input", Input).value = "mywork" + app.screen.query_one("#ok-btn", Button).press() + await pilot.pause() + assert results == [("claude", "mywork", None, None)], f"got {results}" + + +@pytest.mark.asyncio +async def test_new_session_worktree_checked_uses_typed_branch(): + """Checked checkbox + typed branch returns that branch.""" + app = HqtApp() + async with app.run_test(size=(120, 40)) as pilot: + from hqt.tui.screens.new_session import NewSessionScreen + from textual.widgets import Button, Checkbox, Input + + results: list = [] + app.push_screen(NewSessionScreen(["claude"], is_git_repo=True), results.append) + await pilot.pause() + app.screen.query_one("#nickname-input", Input).value = "My Work" + app.screen.query_one("#worktree-checkbox", Checkbox).value = True + await pilot.pause() + # Branch input is revealed and prefilled from the nickname slug. + branch = app.screen.query_one("#branch-input", Input) + assert branch.display is True + assert branch.value == "my-work" + # User overrides with their own branch name. + branch.value = "feature-x" + app.screen.query_one("#ok-btn", Button).press() + await pilot.pause() + assert results == [("claude", "My Work", None, "feature-x")], f"got {results}" + + +@pytest.mark.asyncio +async def test_new_session_worktree_checked_blank_branch_slugifies_nickname(): + """Checked checkbox + blank branch derives the branch from the nickname slug.""" + app = HqtApp() + async with app.run_test(size=(120, 40)) as pilot: + from hqt.tui.screens.new_session import NewSessionScreen + from textual.widgets import Button, Checkbox, Input + + results: list = [] + app.push_screen(NewSessionScreen(["claude"], is_git_repo=True), results.append) + await pilot.pause() + app.screen.query_one("#nickname-input", Input).value = "Cool Feature!" + cb = app.screen.query_one("#worktree-checkbox", Checkbox) + cb.value = True + await pilot.pause() + # Clear the prefilled branch so _submit must re-derive it from nickname. + app.screen.query_one("#branch-input", Input).value = "" + app.screen.query_one("#ok-btn", Button).press() + await pilot.pause() + assert results == [("claude", "Cool Feature!", None, "cool-feature")], ( + f"got {results}" + ) + + +@pytest.mark.asyncio +async def test_new_session_branch_input_hidden_until_checked(): + """The branch input starts hidden and toggles with the checkbox.""" + app = HqtApp() + async with app.run_test(size=(120, 40)) as pilot: + from hqt.tui.screens.new_session import NewSessionScreen + from textual.widgets import Checkbox, Input + + app.push_screen(NewSessionScreen(["claude"], is_git_repo=True)) + await pilot.pause() + branch = app.screen.query_one("#branch-input", Input) + assert branch.display is False + cb = app.screen.query_one("#worktree-checkbox", Checkbox) + cb.value = True + await pilot.pause() + assert branch.display is True + cb.value = False + await pilot.pause() + assert branch.display is False + + +# --------------------------------------------------------------------------- +# Worktree session UI: ConfirmDeleteScreen +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_confirm_delete_checkbox_checked_by_default_when_no_warning(): + """No warning (clean worktree) → 'remove worktree' pre-checked → (True, True).""" + app = HqtApp() + async with app.run_test(size=(120, 40)) as pilot: + from hqt.tui.screens.confirm_delete import ConfirmDeleteScreen + from textual.widgets import Button, Checkbox + + results: list = [] + app.push_screen(ConfirmDeleteScreen("mysess"), results.append) + await pilot.pause() + assert app.screen.query_one("#remove-worktree-checkbox", Checkbox).value is True + app.screen.query_one("#ok-btn", Button).press() + await pilot.pause() + assert results == [(True, True)] + + +@pytest.mark.asyncio +async def test_confirm_delete_checkbox_unchecked_by_default_with_warning(): + """A warning (dirty/unmerged) → checkbox unchecked → (True, False) by default.""" + app = HqtApp() + async with app.run_test(size=(120, 40)) as pilot: + from hqt.tui.screens.confirm_delete import ConfirmDeleteScreen + from textual.widgets import Button, Checkbox, Label + + results: list = [] + app.push_screen( + ConfirmDeleteScreen("mysess", warning="Work will be lost"), + results.append, + ) + await pilot.pause() + assert ( + app.screen.query_one("#remove-worktree-checkbox", Checkbox).value is False + ) + warning = app.screen.query_one("#delete-warning", Label) + assert "Work will be lost" in str(warning.render()) + app.screen.query_one("#ok-btn", Button).press() + await pilot.pause() + assert results == [(True, False)] + + +@pytest.mark.asyncio +async def test_confirm_delete_cancel_returns_none(): + app = HqtApp() + async with app.run_test(size=(120, 40)) as pilot: + from hqt.tui.screens.confirm_delete import ConfirmDeleteScreen + from textual.widgets import Button + + results: list = [] + app.push_screen(ConfirmDeleteScreen("mysess"), results.append) + await pilot.pause() + app.screen.query_one("#cancel-btn", Button).press() + await pilot.pause() + assert results == [None] + + +# --------------------------------------------------------------------------- +# Worktree session UI: list marker + non-worktree delete unchanged +# --------------------------------------------------------------------------- + + +def test_format_session_text_appends_worktree_branch_marker(): + from hqt.tui.widgets.session_list import format_session_text + + text = format_session_text("mywork", "claude", "idle", "●", "feature-x") + assert "⎇ feature-x" in text + + +def test_format_session_text_no_marker_when_nickname_equals_branch(): + """Avoid duplication: when nickname already equals the branch, no marker.""" + from hqt.tui.widgets.session_list import format_session_text + + text = format_session_text("feature-x", "claude", "idle", "●", "feature-x") + assert "⎇" not in text + + +def test_format_session_text_no_marker_for_plain_session(): + from hqt.tui.widgets.session_list import format_session_text + + text = format_session_text("plain", "claude", "idle", "●", None) + assert "⎇" not in text + + +@pytest.mark.asyncio +async def test_delete_non_worktree_session_does_not_push_modal(): + """Plain (non-worktree) sessions delete immediately, with no confirm modal.""" + from hqt.db.models import Harness, Project, Session + from hqt.tui.widgets.session_list import SessionList + from hqt.tui.screens.confirm_delete import ConfirmDeleteScreen + + app = HqtApp() + async with app.run_test(size=(120, 40)) as pilot: + seed_db = app._db_factory() + proj = Project(name="del-proj", path="/tmp/del-proj") + seed_db.add(proj) + seed_db.flush() + harness = seed_db.query(Harness).first() + sess = Session( + project_id=proj.id, + harness_id=harness.id, + tmux_session_name="hqt-del", + archived=False, + ) + seed_db.add(sess) + seed_db.commit() + proj_id = proj.id + sess_id = sess.id + seed_db.close() + + app._selected_project_id = proj_id + await app._refresh_sessions() + await pilot.pause() + + sl = app.query_one(SessionList) + sl.query_one("#session-list").focus() + await pilot.press("down") + await pilot.pause() + assert sl.get_selected_session_id() == sess_id + + await app.run_action("delete_session") + await pilot.pause() + await app.workers.wait_for_complete() + await pilot.pause() + + # No confirm modal was pushed; the session is gone from the DB. + assert not isinstance(app.screen, ConfirmDeleteScreen) + verify_db = app._db_factory() + assert verify_db.get(Session, sess_id) is None + verify_db.close()