"""A session that settles into another git worktree re-anchors onto it. The desktop already follows a session that moves (``followActiveSessionCwd`` refreshes the project tree and scopes into the new project), but it only ever sees a move when the backend reports one on ``session.info``. These tests exercise the backend half against real git worktrees on disk. """ from __future__ import annotations import os import subprocess import pytest import tools.terminal_tool as terminal_tool import tui_gateway.server as server def _git(cwd, *args): subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True) @pytest.fixture def repo_with_worktree(tmp_path): """A real repo on ``main`` plus a linked worktree on ``feature``.""" repo = tmp_path / "proj" repo.mkdir() _git(repo, "init", "-b", "main") _git(repo, "config", "user.email", "t@example.com") _git(repo, "config", "user.name", "t") (repo / "README.md").write_text("hi\n", encoding="utf-8") _git(repo, "add", ".") _git(repo, "commit", "-m", "init") worktree = tmp_path / "proj-feature" _git(repo, "worktree", "add", "-b", "feature", str(worktree)) from tui_gateway import git_probe git_probe.invalidate() yield repo, worktree git_probe.invalidate() @pytest.fixture def session(repo_with_worktree): repo, _ = repo_with_worktree key = "sess-follow" terminal_tool.clear_session_cwd(key) yield {"session_key": key, "cwd": str(repo), "source": "desktop"} terminal_tool.clear_session_cwd(key) @pytest.fixture(autouse=True) def _no_db(monkeypatch): monkeypatch.setattr(server, "_get_db", lambda: None) monkeypatch.setattr(server, "_persist_session_git_meta", lambda *_a: None) monkeypatch.setattr(server, "_register_session_cwd", lambda _s: None) monkeypatch.setattr(server, "_is_local_terminal_backend", lambda: True) def test_settling_in_a_worktree_reanchors_the_session(session, repo_with_worktree): """The whole reported bug: work goes to the worktree, the session says main.""" _, worktree = repo_with_worktree terminal_tool.record_session_cwd(session["session_key"], str(worktree)) assert server._reconcile_session_cwd_from_terminal(session) is True assert session["cwd"] == str(worktree) # The move is the session's workspace now, so it earns a persisted row. assert session["explicit_cwd"] is True def test_a_subdirectory_of_the_same_checkout_is_not_a_move(session, repo_with_worktree): repo, _ = repo_with_worktree sub = repo / "src" sub.mkdir() terminal_tool.record_session_cwd(session["session_key"], str(sub)) assert server._reconcile_session_cwd_from_terminal(session) is False assert session["cwd"] == str(repo) def test_browsing_outside_a_repo_is_not_a_move(session, repo_with_worktree, tmp_path): """`cd /tmp` to read a log must not re-home the workspace.""" repo, _ = repo_with_worktree scratch = tmp_path / "scratch" scratch.mkdir() terminal_tool.record_session_cwd(session["session_key"], str(scratch)) assert server._reconcile_session_cwd_from_terminal(session) is False assert session["cwd"] == str(repo) def test_a_non_git_workspace_is_not_hijacked_by_visiting_a_repo( session, repo_with_worktree, tmp_path ): """A non-git workspace must not be re-homed when a tool call steps into a git repo: browsing in to read a file or run a command is a visit, not a relocation. Otherwise the first git directory the agent touches (e.g. its own install checkout) hijacks a home-directory session. """ repo, _ = repo_with_worktree home = tmp_path / "home" # a plain, non-git workspace home.mkdir() session["cwd"] = str(home) terminal_tool.record_session_cwd(session["session_key"], str(repo)) assert server._reconcile_session_cwd_from_terminal(session) is False assert session["cwd"] == str(home) def test_a_deleted_directory_is_not_a_move(session, repo_with_worktree, tmp_path): repo, _ = repo_with_worktree terminal_tool.record_session_cwd(session["session_key"], str(tmp_path / "gone")) assert server._reconcile_session_cwd_from_terminal(session) is False assert session["cwd"] == str(repo) def test_an_unrelated_repo_is_not_a_move(session, repo_with_worktree, tmp_path): """Git workspace A visiting unrelated git repo B is a visit, not a re-home. Only checkouts sharing the same common .git dir (the shape `git worktree add` produces) count as a relocation; `cd ~/other-project && git log` must not re-anchor the chat onto the foreign repo. """ repo, _ = repo_with_worktree other = tmp_path / "other" other.mkdir() _git(other, "init", "-b", "main") _git(other, "config", "user.email", "t@example.com") _git(other, "config", "user.name", "t") (other / "x.txt").write_text("x\n", encoding="utf-8") _git(other, "add", ".") _git(other, "commit", "-m", "init") terminal_tool.record_session_cwd(session["session_key"], str(other)) assert server._reconcile_session_cwd_from_terminal(session) is False assert session["cwd"] == str(repo) def test_an_explicit_workspace_is_never_overridden(session, repo_with_worktree): """A user-chosen cwd must survive even a legitimate same-repo worktree move.""" repo, worktree = repo_with_worktree session["explicit_cwd"] = True terminal_tool.record_session_cwd(session["session_key"], str(worktree)) assert server._reconcile_session_cwd_from_terminal(session) is False assert session["cwd"] == str(repo) def test_a_settle_adopted_cwd_can_keep_following(session, repo_with_worktree): """The settle marker keeps a session following the agent across worktrees.""" repo, worktree = repo_with_worktree terminal_tool.record_session_cwd(session["session_key"], str(worktree)) assert server._reconcile_session_cwd_from_terminal(session) is True assert session["explicit_cwd"] is True assert session["cwd_from_settle"] is True # Agent moves back to the primary checkout: still follows. terminal_tool.record_session_cwd(session["session_key"], str(repo)) assert server._reconcile_session_cwd_from_terminal(session) is True assert session["cwd"] == str(repo) def test_remote_backends_do_not_reanchor(session, repo_with_worktree, monkeypatch): """A remote cwd names a path on the host, not one this gateway can probe.""" repo, worktree = repo_with_worktree monkeypatch.setattr(server, "_is_local_terminal_backend", lambda: False) terminal_tool.record_session_cwd(session["session_key"], str(worktree)) assert server._reconcile_session_cwd_from_terminal(session) is False assert session["cwd"] == str(repo) def test_settled_session_info_reports_the_worktree_branch( session, repo_with_worktree, monkeypatch ): """End of turn: the emitted session.info is what the desktop follows.""" _, worktree = repo_with_worktree emitted: list[tuple[str, str, dict]] = [] monkeypatch.setattr(server, "_emit", lambda ev, sid, payload=None: emitted.append((ev, sid, payload or {}))) terminal_tool.record_session_cwd(session["session_key"], str(worktree)) server._emit_settled_session_info("sid-1", session, agent=None) assert len(emitted) == 1 event, sid, payload = emitted[0] assert (event, sid) == ("session.info", "sid-1") assert payload["cwd"] == str(worktree) assert payload["branch"] == "feature" def test_reconcile_ignores_a_foreign_sessions_record(session, repo_with_worktree): """cwd records are per session key — another chat's move must not leak in.""" repo, worktree = repo_with_worktree terminal_tool.record_session_cwd("someone-else", str(worktree)) assert server._reconcile_session_cwd_from_terminal(session) is False assert session["cwd"] == str(repo) terminal_tool.clear_session_cwd("someone-else") def test_os_normalized_paths_are_not_a_move(session, repo_with_worktree): """A trailing-slash / unnormalized record is the same dir, not a relocation.""" repo, _ = repo_with_worktree terminal_tool.record_session_cwd(session["session_key"], str(repo) + os.sep) assert server._reconcile_session_cwd_from_terminal(session) is False