# Worktree-Isolated Sessions — Design Date: 2026-06-10 Status: Approved ## Goal Allow a new session to run in a git worktree of its project so the harness operates on an isolated checkout, on its own branch, without trampling the main checkout or other sessions. ## Decisions (from brainstorming) - **Lifecycle:** Feature-branch work. hqt creates worktrees but never auto-deletes work. Cleanup is explicit and guarded. - **Branching:** The user names the branch in the new-session dialog; hqt creates it with `git worktree add -b ` based off the project's current HEAD. No existing-branch checkout, no auto-generated names. - **Location:** `/.worktrees/`. hqt ensures `.worktrees/` is listed in the repo's `.git/info/exclude` (idempotent append; resolved via `git rev-parse --git-common-dir` so it works when the project itself is a worktree). - **Deletion:** Deleting a worktree session prompts with an "Also remove worktree" option and a safety check. Dirty trees or branches with commits unreachable from other refs require explicit opt-in (maps to `git worktree remove --force`). Branches are only deleted with safe `git branch -d`, so unmerged branches always survive. - **Dialog UX:** Checkbox "Isolate in worktree" + branch input (prefilled from the slugified nickname). Disabled with a hint when the project is not a git repo. - **Setup:** None. Fresh worktrees have no deps/env files; the agent in the session handles its own setup. - **Base ref:** Current HEAD of the main checkout. ## Architecture (Approach A) ### Data model `Session` gains two nullable columns; both NULL means a normal session (no boolean flag): - `worktree_path: str | None` — absolute path of the worktree - `worktree_branch: str | None` — branch name Migration version 2 in `hqt/db/migrations.py` `MIGRATIONS` with two `ALTER TABLE sessions ADD COLUMN` statements. ### Git layer: `src/hqt/git/worktree.py` Async subprocess wrappers around git, mirroring `tmux/runner.py`. Failures raise `ServiceError` carrying git's stderr. - `slugify(text) -> str` — lowercase, keep alphanumerics, collapse runs of other chars to `-`, strip leading/trailing `-`. - `is_git_repo(path) -> bool` — `git rev-parse --is-inside-work-tree`. - `create_worktree(repo, branch) -> Path` — validate branch name (`git check-ref-format --branch`), ensure `.worktrees/` in `.git/info/exclude`, run `git worktree add -b .worktrees/`. Returns absolute worktree path. - `worktree_state(repo, path, branch) -> WorktreeState` — dataclass: `exists: bool`, `dirty: bool` (`git status --porcelain` in the worktree), `unique_commits: int` (commits on `branch` not reachable from any other ref). - `remove_worktree(repo, path, branch, force) -> None` — `git worktree remove` (`--force` only when the user confirmed past the warning), then best-effort `git branch -d` (keep branch if unmerged), then `git worktree prune`. - `add_worktree_for_branch(repo, branch) -> Path` — `git worktree add ` (no `-b`); used to recreate a vanished worktree on attach. ### SessionService changes - `create_session(..., worktree_branch: str | None = None)`. When set: resolve project path → `create_worktree` (failure raises ServiceError before any row exists) → create Session row with `worktree_path` / `worktree_branch` → spawn with `cwd=worktree_path`. - `_session_path(db, sess) -> Path` returns `Path(sess.worktree_path) or project.path` and replaces `_project_path` at every harness-facing call site: `build_spawn_config`, `build_resume_config`, and `capture_session_id` (Claude keys session files by cwd, which is now the worktree). - Attach rung 0: if `worktree_path` is set but missing on disk and the branch still exists, recreate via `add_worktree_for_branch` and continue the normal resume ladder. If the branch is gone too, raise `ServiceError("Worktree and branch are gone; delete the session")`. - `delete_session(session_id, remove_worktree: bool = False, force: bool = False)`. When removal is requested and fails, neither the worktree nor the DB row is deleted (no half-deleted state); the error surfaces as a notification. ### TUI changes - `NewSessionScreen`: constructor gains `is_git_repo: bool`. Adds a "Isolate in worktree" checkbox (disabled + "(not a git repo)" hint when false) and a branch `Input`, hidden until checked, prefilled with the slugified nickname (editable). Result tuple extends with `worktree_branch: str | None`. - New `ConfirmDeleteScreen` modal, used only for worktree sessions (non-worktree `d` keeps today's instant behavior): shows session name, "Also remove worktree" checkbox. The app fetches `worktree_state` before pushing it; if dirty or `unique_commits > 0`, show a warning line ("N uncommitted files, M unmerged commits — work will be lost") and start the checkbox unchecked; checked-through-warning maps to `force=True`. - Session list rows and tmux window labels mark worktree sessions with `⎇ `; nickname defaults to the branch name when left empty. ## Corner cases - Branch exists / invalid name / leftover `.worktrees/` dir → git fails, ServiceError notification, no session row. - Non-git project → checkbox disabled; service raises if called anyway. - Project path deleted with live worktree sessions → same as today (capture skipped, attach errors). - Submodules are not initialized in fresh worktrees — documented limitation. - `git worktree remove` fails (e.g. process cwd inside) → notification, session row kept. ## Testing - `tests/test_worktree.py`: git module against real temp git repos (init, commit; create/remove/state; exclude idempotence; invalid names). - Service tests: monkeypatch worktree functions; verify ordering (worktree before row, no row on failure), spawn/resume/capture cwd, attach rung 0, guarded delete. - TUI tests: dialog return values, checkbox-disabled path, confirm modal wiring.