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

5.9 KiB

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 <branch> based off the project's current HEAD. No existing-branch checkout, no auto-generated names.
  • Location: <project>/.worktrees/<branch>. 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) -> boolgit 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 <branch> .worktrees/<branch>. 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) -> Nonegit 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) -> Pathgit worktree add <path> <branch> (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 ⎇ <branch>; nickname defaults to the branch name when left empty.

Corner cases

  • Branch exists / invalid name / leftover .worktrees/<branch> 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.