feat: worktree-aware session lifecycle in SessionService
Add worktree support to SessionService via the hqt.git.worktree module: - create_session(worktree_branch=...): creates the worktree before the row (ServiceError → no row, no spawn); spawn/capture use the worktree path; blank nickname defaults to the branch name. - _session_path / _repo_path_for_worktree helpers route every harness-facing path through the worktree when set, with a parent.parent repo fallback when the project row is gone. - attach_session rung 0: recreate a missing worktree from its branch, else raise "delete the session". - delete_session(remove_worktree=, force=): remove worktree before deleting the row; removal failure keeps the row. - worktree_state_for(session_id): None for plain/missing sessions, else the module's WorktreeState. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1106,3 +1106,330 @@ async def test_list_sessions_skips_capture_for_deleted_project(
|
||||
harnesses_capture["claude-code"].capture_session_id.return_value = "would-capture"
|
||||
infos = await service.list_sessions(1)
|
||||
assert len(infos) == 1 # no ServiceError escaped
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 3: worktree-aware session lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
WT_PATH = Path("/tmp/myproj/.worktrees/feature-x")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_worktree(monkeypatch):
|
||||
"""Monkeypatch hqt.git.worktree functions with recording async fakes.
|
||||
|
||||
Returns a namespace exposing the recorded call args and the WorktreeState
|
||||
class so tests can assert ordering and arguments.
|
||||
"""
|
||||
import hqt.git.worktree as wt_mod
|
||||
|
||||
class Recorder:
|
||||
def __init__(self):
|
||||
self.create_calls = []
|
||||
self.add_calls = []
|
||||
self.remove_calls = []
|
||||
self.state_calls = []
|
||||
self.create_path = WT_PATH
|
||||
self.create_error: Exception | None = None
|
||||
self.add_error: Exception | None = None
|
||||
self.remove_error: Exception | None = None
|
||||
self.state_result = wt_mod.WorktreeState(
|
||||
exists=True, dirty=False, unique_commits=2
|
||||
)
|
||||
|
||||
async def create_worktree(self, repo, branch):
|
||||
self.create_calls.append((repo, branch))
|
||||
if self.create_error is not None:
|
||||
raise self.create_error
|
||||
return self.create_path
|
||||
|
||||
async def add_worktree_for_branch(self, repo, branch):
|
||||
self.add_calls.append((repo, branch))
|
||||
if self.add_error is not None:
|
||||
raise self.add_error
|
||||
return self.create_path
|
||||
|
||||
async def remove_worktree(self, repo, path, branch, force):
|
||||
self.remove_calls.append((repo, path, branch, force))
|
||||
if self.remove_error is not None:
|
||||
raise self.remove_error
|
||||
|
||||
async def worktree_state(self, repo, path, branch):
|
||||
self.state_calls.append((repo, path, branch))
|
||||
return self.state_result
|
||||
|
||||
rec = Recorder()
|
||||
monkeypatch.setattr(wt_mod, "create_worktree", rec.create_worktree)
|
||||
monkeypatch.setattr(wt_mod, "add_worktree_for_branch", rec.add_worktree_for_branch)
|
||||
monkeypatch.setattr(wt_mod, "remove_worktree", rec.remove_worktree)
|
||||
monkeypatch.setattr(wt_mod, "worktree_state", rec.worktree_state)
|
||||
return rec
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_worktree_branch(
|
||||
service, db, tmux, harnesses, fake_worktree
|
||||
):
|
||||
"""worktree_branch set → create_worktree called with (project_path, branch);
|
||||
spawn cwd and capture use the worktree path; row stores both fields."""
|
||||
harnesses["claude-code"].captures_session_id = True
|
||||
harnesses["claude-code"].capture_session_id.return_value = "cap-id"
|
||||
harnesses["claude-code"].build_spawn_config.return_value = MagicMock(
|
||||
command=["claude"], env={}, cwd=WT_PATH
|
||||
)
|
||||
|
||||
result = await service.create_session(
|
||||
project_id=1, harness_name="claude-code", worktree_branch="feature-x"
|
||||
)
|
||||
|
||||
# create_worktree called with project path + branch
|
||||
assert fake_worktree.create_calls == [(Path("/tmp/myproj"), "feature-x")]
|
||||
# spawn request cwd is the worktree path
|
||||
spawn_req = tmux.spawn.call_args.args[0]
|
||||
assert spawn_req.cwd == str(WT_PATH)
|
||||
# build_spawn_config used the worktree path, not the project path
|
||||
assert harnesses["claude-code"].build_spawn_config.call_args.args[0] == WT_PATH
|
||||
# capture used the worktree path
|
||||
assert harnesses["claude-code"].capture_session_id.call_args.args[0] == WT_PATH
|
||||
# row carries both worktree fields
|
||||
sess = result.session
|
||||
assert sess.worktree_path == str(WT_PATH)
|
||||
assert sess.worktree_branch == "feature-x"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_worktree_blank_nickname_defaults_to_branch(
|
||||
service, db, tmux, harnesses, fake_worktree
|
||||
):
|
||||
"""Blank nickname + worktree_branch → nickname defaults to branch name."""
|
||||
result = await service.create_session(
|
||||
project_id=1,
|
||||
harness_name="claude-code",
|
||||
nickname="",
|
||||
worktree_branch="feature-x",
|
||||
)
|
||||
assert result.session.nickname == "feature-x"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_worktree_explicit_nickname_preserved(
|
||||
service, db, tmux, harnesses, fake_worktree
|
||||
):
|
||||
"""Explicit nickname + worktree_branch → nickname preserved."""
|
||||
result = await service.create_session(
|
||||
project_id=1,
|
||||
harness_name="claude-code",
|
||||
nickname="my-work",
|
||||
worktree_branch="feature-x",
|
||||
)
|
||||
assert result.session.nickname == "my-work"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_worktree_create_fails_no_row_no_spawn(
|
||||
factory, db, tmux, harnesses, fake_worktree
|
||||
):
|
||||
"""create_worktree raising ServiceError → propagates, no Session row, no spawn."""
|
||||
from hqt.db.models import Session
|
||||
|
||||
fake_worktree.create_error = ServiceError("git worktree add failed")
|
||||
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
|
||||
|
||||
with pytest.raises(ServiceError, match="worktree add failed"):
|
||||
await service.create_session(
|
||||
project_id=1, harness_name="claude-code", worktree_branch="feature-x"
|
||||
)
|
||||
|
||||
# No Session row committed
|
||||
db.expire_all()
|
||||
assert db.query(Session).count() == 0
|
||||
# tmux spawn never called
|
||||
tmux.spawn.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_without_worktree_branch_regression(
|
||||
service, db, tmux, harnesses, fake_worktree
|
||||
):
|
||||
"""No worktree_branch → create_worktree not called, row worktree fields None."""
|
||||
result = await service.create_session(project_id=1, harness_name="claude-code")
|
||||
assert fake_worktree.create_calls == []
|
||||
assert result.session.worktree_path is None
|
||||
assert result.session.worktree_branch is None
|
||||
|
||||
|
||||
def _seed_worktree_session(db, branch="feature-x", path=str(WT_PATH)):
|
||||
"""Seed a worktree session for project 1 / harness claude-code."""
|
||||
from hqt.db.models import Harness, Session
|
||||
|
||||
harness = db.query(Harness).filter_by(name="claude-code").first()
|
||||
sess = Session(
|
||||
project_id=1,
|
||||
harness_id=harness.id,
|
||||
tmux_session_name="hqt-wt",
|
||||
harness_session_id="sess-1",
|
||||
worktree_path=path,
|
||||
worktree_branch=branch,
|
||||
archived=False,
|
||||
)
|
||||
db.add(sess)
|
||||
db.commit()
|
||||
return sess
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attach_session_recreates_missing_worktree(
|
||||
factory, db, tmux, harnesses, fake_worktree
|
||||
):
|
||||
"""attach_session: worktree dir missing on disk → add_worktree_for_branch called."""
|
||||
# Use a path that does not exist on disk.
|
||||
missing = "/tmp/does-not-exist/.worktrees/feature-x"
|
||||
sess = _seed_worktree_session(db, path=missing)
|
||||
|
||||
harnesses["claude-code"].build_resume_config = MagicMock(
|
||||
return_value=MagicMock(command=["claude"], env={}, cwd=Path(missing))
|
||||
)
|
||||
tmux.is_alive = AsyncMock(return_value=False)
|
||||
tmux.respawn_verified = AsyncMock(
|
||||
return_value=SpawnResult(ok=True, window_id=None, error="")
|
||||
)
|
||||
tmux.attach = AsyncMock(return_value=True)
|
||||
|
||||
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
|
||||
await service.attach_session(session_id=sess.id)
|
||||
|
||||
assert fake_worktree.add_calls == [(Path("/tmp/myproj"), "feature-x")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attach_session_missing_worktree_recreate_fails(
|
||||
factory, db, tmux, harnesses, fake_worktree
|
||||
):
|
||||
"""attach_session: recreate raises → ServiceError mentioning deleting the session."""
|
||||
missing = "/tmp/does-not-exist/.worktrees/feature-x"
|
||||
sess = _seed_worktree_session(db, path=missing)
|
||||
fake_worktree.add_error = ServiceError("branch gone")
|
||||
|
||||
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
|
||||
with pytest.raises(ServiceError, match="delete the session"):
|
||||
await service.attach_session(session_id=sess.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attach_session_worktree_resume_uses_worktree_path(
|
||||
factory, db, tmux, harnesses, fake_worktree
|
||||
):
|
||||
"""Resume config for a worktree session uses the worktree path as cwd."""
|
||||
# WT_PATH parent exists (/tmp/myproj/.worktrees won't, but use a real existing dir)
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sess = _seed_worktree_session(db, path=tmpdir)
|
||||
|
||||
resume_cfg = MagicMock(command=["claude", "--resume"], env={}, cwd=Path(tmpdir))
|
||||
harnesses["claude-code"].build_resume_config = MagicMock(
|
||||
return_value=resume_cfg
|
||||
)
|
||||
tmux.is_alive = AsyncMock(return_value=False)
|
||||
tmux.respawn_verified = AsyncMock(
|
||||
return_value=SpawnResult(ok=True, window_id=None, error="")
|
||||
)
|
||||
tmux.attach = AsyncMock(return_value=True)
|
||||
|
||||
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
|
||||
await service.attach_session(session_id=sess.id)
|
||||
|
||||
# build_resume_config received the worktree path as the project path arg
|
||||
assert harnesses["claude-code"].build_resume_config.call_args.args[0] == Path(
|
||||
tmpdir
|
||||
)
|
||||
# since the worktree exists on disk, recreate must not have been attempted
|
||||
assert fake_worktree.add_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_session_remove_worktree_true(
|
||||
factory, db, tmux, harnesses, fake_worktree
|
||||
):
|
||||
"""delete_session(remove_worktree=True) calls remove_worktree and deletes the row."""
|
||||
from hqt.db.models import Session
|
||||
|
||||
sess = _seed_worktree_session(db)
|
||||
sess_id = sess.id
|
||||
|
||||
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
|
||||
await service.delete_session(sess_id, remove_worktree=True, force=True)
|
||||
|
||||
assert fake_worktree.remove_calls == [
|
||||
(Path("/tmp/myproj"), WT_PATH, "feature-x", True)
|
||||
]
|
||||
db.expire_all()
|
||||
assert db.get(Session, sess_id) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_session_remove_worktree_raises_keeps_row(
|
||||
factory, db, tmux, harnesses, fake_worktree
|
||||
):
|
||||
"""remove_worktree raising → ServiceError propagates, row still present."""
|
||||
from hqt.db.models import Session
|
||||
|
||||
sess = _seed_worktree_session(db)
|
||||
sess_id = sess.id
|
||||
fake_worktree.remove_error = ServiceError("worktree dirty")
|
||||
|
||||
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
|
||||
with pytest.raises(ServiceError, match="worktree dirty"):
|
||||
await service.delete_session(sess_id, remove_worktree=True)
|
||||
|
||||
db.expire_all()
|
||||
assert db.get(Session, sess_id) is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_session_remove_worktree_false_skips_removal(
|
||||
factory, db, tmux, harnesses, fake_worktree
|
||||
):
|
||||
"""remove_worktree=False → remove_worktree not called, row deleted."""
|
||||
from hqt.db.models import Session
|
||||
|
||||
sess = _seed_worktree_session(db)
|
||||
sess_id = sess.id
|
||||
|
||||
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
|
||||
await service.delete_session(sess_id, remove_worktree=False)
|
||||
|
||||
assert fake_worktree.remove_calls == []
|
||||
db.expire_all()
|
||||
assert db.get(Session, sess_id) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worktree_state_for_plain_session_returns_none(
|
||||
service, db, tmux, harnesses, fake_worktree
|
||||
):
|
||||
"""worktree_state_for: plain session → None."""
|
||||
sess = _seed_session(db)
|
||||
assert await service.worktree_state_for(sess.id) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worktree_state_for_missing_id_returns_none(service, db, fake_worktree):
|
||||
"""worktree_state_for: unknown session id → None."""
|
||||
assert await service.worktree_state_for(99999) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worktree_state_for_worktree_session_returns_state(
|
||||
factory, db, tmux, harnesses, fake_worktree
|
||||
):
|
||||
"""worktree_state_for: worktree session → returns the module's WorktreeState."""
|
||||
sess = _seed_worktree_session(db)
|
||||
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
|
||||
|
||||
state = await service.worktree_state_for(sess.id)
|
||||
|
||||
assert state is fake_worktree.state_result
|
||||
assert fake_worktree.state_calls == [(Path("/tmp/myproj"), WT_PATH, "feature-x")]
|
||||
|
||||
Reference in New Issue
Block a user