Files
hqt/docs/superpowers/plans/2026-06-10-worktree-sessions-plan.md
T
2026-06-10 19:48:20 -04:00

12 KiB

Worktree-Isolated Sessions — Implementation Plan

Spec: docs/superpowers/specs/2026-06-10-worktree-sessions-design.md Branch: worktree-sessions

Quality gates after every task (must pass before commit):

uv run ruff format src tests
uv run ruff check src tests
uv run ty check
uv run pytest -q

Do NOT stage src/hqt/tmux/runner.py — it carries an unrelated uncommitted user change. Stage only files you created/modified for your task.

Task 1 — Git worktree module

Create src/hqt/git/__init__.py and src/hqt/git/worktree.py, plus tests/test_worktree.py.

Module contents (async functions use asyncio.create_subprocess_exec, in the style of src/hqt/tmux/runner.py; failures raise hqt.errors.ServiceError with git's stderr included):

  • slugify(text: str) -> str — lowercase; keep [a-z0-9]; collapse runs of anything else into single -; strip leading/trailing -. Pure function.
  • async is_git_repo(path: Path) -> boolgit rev-parse --is-inside-work-tree in path; False on any failure (including missing directory).
  • WorktreeState dataclass: exists: bool, dirty: bool, unique_commits: int.
  • async create_worktree(repo: Path, branch: str) -> Path:
    1. Validate with git check-ref-format --branch <branch> → ServiceError "Invalid branch name: ..." on failure.
    2. Resolve git common dir (git rev-parse --git-common-dir, relative results resolved against repo); append line .worktrees/ to <common-dir>/info/exclude if not already present (create file if missing).
    3. git worktree add -b <branch> <repo>/.worktrees/<branch> (cwd=repo). ServiceError with stderr on failure (branch exists, dir exists, etc.).
    4. Return the absolute worktree path.
  • async add_worktree_for_branch(repo: Path, branch: str) -> Pathgit worktree add <repo>/.worktrees/<branch> <branch> (no -b), same exclude handling, for recreating a vanished worktree.
  • async worktree_state(repo: Path, path: Path, branch: str) -> WorktreeStateexists = path exists on disk; dirty = non-empty git status --porcelain run in the worktree (False if not exists); unique_commits = line count of git rev-list <branch> --not --all --count-style query. Use git rev-list --count <branch> --not --exclude=refs/heads/<branch> --all... simplest correct form: git for-each-ref --format='%(refname)' refs/heads refs/remotes, drop the branch's own ref, then git rev-list --count <branch> --not <others...> (0 when no other refs exist is acceptable only if branch tip is reachable from them; with no other refs, count commits on branch). Implement and test against a real repo; exact plumbing is the implementer's choice as long as the tests below pass.
  • async remove_worktree(repo: Path, path: Path, branch: str, force: bool) -> Nonegit worktree remove [--force] <path> (ServiceError on failure), then best-effort git branch -d <branch> (ignore failure), then git worktree prune.

Tests (real git in tmp_path repos; configure user.name/email; create an initial commit):

  • slugify: mixed case, spaces, punctuation, leading/trailing junk, empty.
  • is_git_repo: repo → True; plain dir → False; missing dir → False.
  • create_worktree: creates .worktrees/<branch> with branch checked out; .git/info/exclude contains .worktrees/; calling again with same branch raises ServiceError; invalid branch name (foo..bar) raises ServiceError; exclude not duplicated after two different worktrees.
  • worktree_state: fresh worktree → exists, not dirty, 0 unique commits; touch a file → dirty; commit on the branch → unique_commits == 1; after removing dir manually → exists False.
  • remove_worktree: clean worktree removed, branch (merged/no unique commits) deleted; dirty worktree without force → ServiceError and dir remains; with force → removed; unmerged branch survives removal (git branch -d fails silently, branch still listed).

Commit message: feat: git worktree module.

Task 2 — Session columns + migration

Modify src/hqt/db/models.py and src/hqt/db/migrations.py; extend tests/test_db.py.

  • Session gains worktree_path: Mapped[str | None] = mapped_column(default=None) and worktree_branch: Mapped[str | None] = mapped_column(default=None).
  • Add migration entry (2, ...) to MIGRATIONS executing two ALTER TABLE sessions ADD COLUMN statements (worktree_path TEXT, worktree_branch TEXT). LATEST_VERSION derives automatically.
  • Tests: fresh DB has the columns and user_version 2; a DB created at baseline (simulate: create tables via models without the new columns is impractical — instead create a v1 DB by issuing the old CREATE TABLE through exec_driver_sql or by building metadata then dropping the two columns is messy; acceptable approach: stamp an existing schema to user_version 1 after removing the columns via SQLite ALTER TABLE ... DROP COLUMN, then run migrate() and assert columns exist and version == 2). Follow the existing migration-test patterns in tests/test_db.py if any.

Commit message: feat: worktree columns on sessions + migration v2.

Task 3 — SessionService worktree support

Modify src/hqt/sessions/service.py; extend tests/test_sessions.py / tests/test_services.py (match where SessionService is currently tested).

  • Import the worktree module as a module (from hqt.git import worktree) so tests can monkeypatch worktree.create_worktree etc.
  • create_session(self, project_id, harness_name, nickname=None, model=None, worktree_branch=None):
    • When worktree_branch is falsy → behavior identical to today.
    • When set: after resolving project_path, await worktree.create_worktree(project_path, worktree_branch) BEFORE adding the Session row; on ServiceError let it propagate (no row, no spawn). Store worktree_path=str(path) and worktree_branch on the row. Spawn config must be built with the worktree path, and the capture-retry loop must also use the worktree path.
    • Default nickname: if worktree_branch set and nickname blank, nickname becomes the branch name.
  • Add _session_path(self, db, sess) -> Path: Path(sess.worktree_path) when set, else self._project_path(db, sess.project_id). Use it in _respawn_with_fallback, _get_resume_config, and _maybe_capture_missing_session_id (replace direct project-path lookups for harness-facing paths; _maybe_capture_missing_session_id keeps its "project deleted → skip" behavior for non-worktree sessions, and for worktree sessions uses the worktree path without needing the project row).
  • attach_session rung 0: before the alive check... no — keep order: if session has worktree_path and the path does not exist on disk:
    • branch exists (git rev-parse --verify refs/heads/<branch> helper in worktree module is NOT needed; use worktree.add_worktree_for_branch and catch ServiceError) → recreate, then continue normal flow.
    • recreate fails → raise ServiceError("Worktree and branch are gone; delete the session").
  • delete_session(self, session_id, remove_worktree=False, force=False):
    • Kill tmux window as today.
    • If remove_worktree and session has worktree: await worktree.remove_worktree(project_path, Path(sess.worktree_path), sess.worktree_branch, force). On ServiceError: do NOT delete the DB row; re-raise (the TUI shows the notification; the tmux window being already killed is acceptable).
    • Then delete row + commit.
  • Expose async worktree_state_for(self, session_id) -> WorktreeState | None returning None for non-worktree sessions; the TUI uses it to build the confirm dialog.

Tests (fake tmux pattern as existing tests; monkeypatch worktree functions with AsyncMock/recording fakes):

  • create_session with worktree_branch: create_worktree called with project path and branch; spawn cwd == worktree path; capture uses worktree path; row has worktree fields; nickname defaults to branch.
  • create_worktree raising → ServiceError propagates, no Session row exists, no spawn attempted.
  • attach with worktree_path missing on disk → add_worktree_for_branch called; on its failure → ServiceError with the delete-hint message.
  • resume config for a worktree session uses worktree path.
  • delete_session remove_worktree=True calls worktree.remove_worktree and deletes row; worktree.remove_worktree raising → row still present.
  • worktree_state_for: None for plain sessions; delegates for worktree ones.

Commit message: feat: worktree-aware session lifecycle in SessionService.

Task 4 — TUI wiring + docs

Modify src/hqt/tui/screens/new_session.py, add src/hqt/tui/screens/confirm_delete.py, modify src/hqt/tui/app.py, src/hqt/tui/widgets/session_list.py, src/hqt/status.py callers if needed, README.md; extend tests/test_tui.py.

  • NewSessionScreen(harness_names, is_git_repo):
    • Result type becomes tuple[str, str, str | None, str | None] | None (harness, nickname, model, worktree_branch).
    • Add Checkbox("Isolate in worktree", id="worktree-checkbox") (textual Checkbox); when is_git_repo is False, disabled=True and label "Isolate in worktree (not a git repo)".
    • Branch Input(id="branch-input") hidden (display = False) until the checkbox is checked; when first revealed, prefill with slugify(nickname) if the field is empty; user edits freely afterwards.
    • On submit with checkbox checked: empty branch → block submit and focus the branch field (or fall back to slugified nickname if non-empty; if both empty, keep dialog open). Unchecked → worktree_branch None.
  • ConfirmDeleteScreen(ModalScreen[tuple[bool, bool] | None]) in new file: constructor takes session_label: str, warning: str | None. Shows "Delete session ?", optional warning line, Checkbox("Also remove worktree", value=warning is None), Delete/Cancel buttons. Returns (confirmed, remove_worktree); remove_worktree and warning is not None means force.
  • app.py:
    • action_new_session: compute is_git_repo via await worktree.is_git_repo(Path(project.path)) (inside the worker or pre-fetched; ProjectService has the path — follow existing access patterns), pass to screen; pass worktree_branch to create_session.
    • action_delete_session: fetch session; if it has no worktree, keep today's immediate delete. Otherwise call sessions.worktree_state_for(sid), build warning string when dirty or unique_commits > 0 ("N uncommitted file(s), M unmerged commit(s) — work will be lost"), push ConfirmDeleteScreen; on (True, remove) call delete_session(sid, remove_worktree=remove, force=remove and warned). ServiceError already surfaces via _run_service_worker.
  • Session list: rows for worktree sessions append ⎇ <branch>; check src/hqt/tui/widgets/session_list.py for the row-label construction.
  • tmux window label: in sync_window_labels, the name passed to window_label for worktree sessions is nickname or branch (nickname already defaults to branch at creation, so no change may be needed — verify and leave a test).
  • README: document the worktree checkbox under Key Bindings/feature notes, and the .worktrees/ location + submodule limitation.
  • Tests: NewSessionScreen returns worktree_branch when checked, None when unchecked, disabled checkbox when not a repo; ConfirmDeleteScreen returns the right tuples; app delete path for non-worktree sessions unchanged (no modal).

Commit message: feat: worktree session UI (dialog checkbox, confirm delete, list marker).