docs: worktree-sessions spec and implementation plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
# 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) -> 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 <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) -> 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 <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.
|
||||
Reference in New Issue
Block a user