docs: worktree-sessions spec and implementation plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <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) -> Path` —
|
||||||
|
`git 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) ->
|
||||||
|
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 <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)
|
||||||
|
-> None` — `git 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 <label>?", 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)`.
|
||||||
@@ -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