From 685f39baec739f40eac58b6202aee076f4773000 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Wed, 10 Jun 2026 19:48:20 -0400 Subject: [PATCH] docs: worktree-sessions spec and implementation plan Co-Authored-By: Claude Fable 5 --- .../2026-06-10-worktree-sessions-plan.md | 216 ++++++++++++++++++ .../2026-06-10-worktree-sessions-design.md | 126 ++++++++++ 2 files changed, 342 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-10-worktree-sessions-plan.md create mode 100644 docs/superpowers/specs/2026-06-10-worktree-sessions-design.md diff --git a/docs/superpowers/plans/2026-06-10-worktree-sessions-plan.md b/docs/superpowers/plans/2026-06-10-worktree-sessions-plan.md new file mode 100644 index 0000000..6efb158 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-worktree-sessions-plan.md @@ -0,0 +1,216 @@ +# 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) -> bool` — `git 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 ` → 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 + `/info/exclude` if not already present (create file if + missing). + 3. `git worktree add -b /.worktrees/` (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) -> Path` — + `git worktree add /.worktrees/ ` (no `-b`), same + exclude handling, for recreating a vanished worktree. +- `async worktree_state(repo: Path, path: Path, branch: str) -> + WorktreeState` — `exists` = 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 --not --all + --count`-style query. Use `git rev-list --count --not + --exclude=refs/heads/ --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 --not ` + (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) + -> None` — `git worktree remove [--force] ` (ServiceError on + failure), then best-effort `git branch -d ` (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/` 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/` 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