685f39baec
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5.9 KiB
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 viagit rev-parse --git-common-dirso 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 safegit 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 worktreeworktree_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, rungit worktree add -b <branch> .worktrees/<branch>. Returns absolute worktree path.worktree_state(repo, path, branch) -> WorktreeState— dataclass:exists: bool,dirty: bool(git status --porcelainin the worktree),unique_commits: int(commits onbranchnot reachable from any other ref).remove_worktree(repo, path, branch, force) -> None—git worktree remove(--forceonly when the user confirmed past the warning), then best-effortgit branch -d(keep branch if unmerged), thengit worktree prune.add_worktree_for_branch(repo, branch) -> Path—git 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 withworktree_path/worktree_branch→ spawn withcwd=worktree_path._session_path(db, sess) -> PathreturnsPath(sess.worktree_path) or project.pathand replaces_project_pathat every harness-facing call site:build_spawn_config,build_resume_config, andcapture_session_id(Claude keys session files by cwd, which is now the worktree).- Attach rung 0: if
worktree_pathis set but missing on disk and the branch still exists, recreate viaadd_worktree_for_branchand continue the normal resume ladder. If the branch is gone too, raiseServiceError("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 gainsis_git_repo: bool. Adds a "Isolate in worktree" checkbox (disabled + "(not a git repo)" hint when false) and a branchInput, hidden until checked, prefilled with the slugified nickname (editable). Result tuple extends withworktree_branch: str | None.- New
ConfirmDeleteScreenmodal, used only for worktree sessions (non-worktreedkeeps today's instant behavior): shows session name, "Also remove worktree" checkbox. The app fetchesworktree_statebefore pushing it; if dirty orunique_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 toforce=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 removefails (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.