from __future__ import annotations import os import re import shlex import shutil from uuid import uuid4 import subprocess from collections.abc import Sequence from dataclasses import dataclass from pathlib import Path from .config import state_dir from .registry import repository_paths, validate_targets class TmuxError(RuntimeError): pass @dataclass class Endpoint: session: str pid: int repos: list[str] instance_id: str = "" created_session: bool = False created_windows: tuple[str, ...] = () @property def endpoint_id(self) -> str: return f"tmux-amq-{self.pid}" @property def instance_key(self) -> str: return self.instance_id or self.endpoint_id @dataclass(frozen=True) class LaunchPlan: repos: tuple[str, ...] paths: dict[str, str] command: tuple[str, ...] class TmuxManager: def __init__( self, session: str = "tamq", *, tmux_command: Sequence[str] | None = None, tamq_command: Sequence[str] | None = None, command_dir: Path | None = None, ): self.session = session socket_name = os.environ.get("TAMQ_TMUX_SOCKET") self.tmux_command = tuple(tmux_command or (["tmux", "-L", socket_name] if socket_name else ["tmux"])) if tamq_command is None: self.tamq_command = (shutil.which("tamq") or "tamq",) else: self.tamq_command = tuple(tamq_command) configured_command_dir = os.environ.get("TAMQ_COMMAND_DIR") if command_dir is not None: self.command_dir = command_dir elif configured_command_dir: self.command_dir = Path(configured_command_dir) elif ( len(self.tamq_command) == 1 and Path(self.tamq_command[0]).is_absolute() and os.access(Path(self.tamq_command[0]).parent, os.W_OK | os.X_OK) ): self.command_dir = Path(self.tamq_command[0]).parent else: self.command_dir = state_dir() / "commands" / session def _install_address_commands(self, repos: Sequence[str]) -> None: """Install tamq-owned commands without modifying a user's shell files.""" self.command_dir.mkdir(parents=True, exist_ok=True, mode=0o700) if not os.access(self.command_dir, os.W_OK | os.X_OK): raise TmuxError(f"address command directory is not writable: {self.command_dir}") for repo in repos: if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", repo) is None: raise TmuxError( f"repository name cannot be exposed as a shell command: {repo!r}" ) target = f"@{repo}:" script = ( "#!/bin/sh\n" "# tamq-address-command v1\n" f"exec {shell_join(list(self.tamq_command))} send -- {shlex.quote(target)} \"$@\"\n" ) for name in (f"@{repo}", target): destination = self.command_dir / name if destination.exists() or destination.is_symlink(): if destination.is_symlink() or not destination.is_file(): raise TmuxError(f"refusing to replace existing command: {destination}") try: existing = destination.read_text(encoding="utf-8") except OSError as exc: raise TmuxError(f"cannot inspect existing command: {destination}") from exc if "# tamq-address-command v1" not in existing: raise TmuxError(f"refusing to replace existing command: {destination}") temporary = self.command_dir / f".{name}.{uuid4().hex}.tmp" temporary.write_text(script, encoding="utf-8") temporary.chmod(0o700) temporary.replace(destination) def _window_environment(self, repo: str) -> tuple[str, ...]: path = os.environ.get("PATH", "") command_path = f"{self.command_dir}{os.pathsep}{path}" if path else str(self.command_dir) return "-e", f"TAMQ_REPO={repo}", "-e", f"PATH={command_path}" def _run(self, *args: str, check: bool = True) -> str: result = subprocess.run([*self.tmux_command, *args], text=True, capture_output=True, check=False) if check and result.returncode: raise TmuxError(result.stderr.strip() or "tmux command failed") return result.stdout.strip() def _existing_session_identity(self) -> tuple[int, str] | None: result = subprocess.run( [*self.tmux_command, "has-session", "-t", self.session], capture_output=True, ) if result.returncode != 0: return None pid_text = self._run("display-message", "-p", "-t", self.session, "#{pid}") try: pid = int(pid_text) except ValueError as exc: raise TmuxError(f"unable to determine tmux PID: {pid_text!r}") from exc instance_id = self._run( "show-options", "-v", "-t", self.session, "@tamq_instance_id", check=False, ) managed = self._run( "show-options", "-v", "-t", self.session, "@tamq_managed", check=False, ) if managed != "1" and not instance_id.startswith(f"tmux-amq-{pid}-"): raise TmuxError( f"tmux session already exists and is not managed by tamq: {self.session}" ) return pid, instance_id def preflight(self, repos: list[str], command: str | None = None) -> LaunchPlan: if not repos: raise TmuxError("at least one repository is required") if not self.tmux_command or shutil.which(self.tmux_command[0]) is None: raise TmuxError("tmux is required; install it and ensure 'tmux' is on PATH") if shutil.which("gita") is None: raise TmuxError("gita is required; install it and ensure 'gita' is on PATH") command_parts: tuple[str, ...] = () if command is not None: try: command_parts = tuple(shlex.split(command)) except ValueError as exc: raise TmuxError(f"invalid initial command: {exc}") from exc if not command_parts: raise TmuxError("initial command must not be empty") if shutil.which(command_parts[0]) is None: raise TmuxError(f"initial command not found on PATH: {command_parts[0]}") unique_repos = tuple(dict.fromkeys(repos)) try: validate_targets(list(unique_repos)) except Exception as exc: raise TmuxError(str(exc)) from exc paths = repository_paths() selected_paths: dict[str, str] = {} for repo in unique_repos: try: repo_path = paths[repo] except KeyError as exc: raise TmuxError(f"gita path missing for registered repository: {repo}") from exc if not Path(repo_path).is_dir(): raise TmuxError(f"gita path is not a directory for registered repository {repo}: {repo_path}") selected_paths[repo] = repo_path self._existing_session_identity() return LaunchPlan(unique_repos, selected_paths, command_parts) def ensure(self, repos: list[str], command: str | None = None) -> Endpoint: return self.ensure_plan(self.preflight(repos, command)) def ensure_plan(self, plan: LaunchPlan, *, tap: bool = False) -> Endpoint: if tap and not plan.command: raise TmuxError("PTY tap requires an explicit initial command") created_session = False created_windows: list[str] = [] try: identity = self._existing_session_identity() if identity is None: first_repo = plan.repos[0] self._run( "new-session", "-d", "-s", self.session, "-n", "__tamq_boot", *self._window_environment(first_repo), "-c", plan.paths[first_repo], ) created_session = True pid_text = self._run("display-message", "-p", "-t", self.session, "#{pid}") try: pid = int(pid_text) except ValueError as exc: raise TmuxError(f"unable to determine tmux PID: {pid_text!r}") from exc instance_id = "" else: pid, instance_id = identity endpoint_id = f"tmux-amq-{pid}" if not instance_id.startswith(f"{endpoint_id}-"): instance_id = f"{endpoint_id}-{uuid4().hex[:12]}" self._run("set-option", "-t", self.session, "@tamq_instance_id", instance_id) self._run("set-option", "-t", self.session, "@tamq_managed", "1") windows = self._run("list-windows", "-t", self.session, "-F", "#{window_name}").splitlines() addressable_repos = [repo for repo in windows if repo != "__tamq_boot"] addressable_repos.extend(repo for repo in plan.repos if repo not in addressable_repos) self._install_address_commands(addressable_repos) command_path = self._window_environment(plan.repos[0])[-1].removeprefix("PATH=") self._run("set-environment", "-t", self.session, "PATH", command_path) self._run("set-option", "-t", self.session, "@tamq_command_dir", str(self.command_dir)) for index, repo in enumerate(plan.repos): if repo in windows: continue if index == 0 and "__tamq_boot" in windows: if not created_session: self._run("respawn-pane", "-k", "-t", f"{self.session}:__tamq_boot", "-c", plan.paths[repo]) self._run("rename-window", "-t", f"{self.session}:__tamq_boot", repo) windows[windows.index("__tamq_boot")] = repo else: self._run( "new-window", "-d", "-t", self.session, "-n", repo, *self._window_environment(repo), "-c", plan.paths[repo], ) windows.append(repo) created_windows.append(repo) if plan.command: command = ( [ *self.tamq_command, "tap", "--repo", repo, "--endpoint", endpoint_id, "--", *plan.command, ] if tap else list(plan.command) ) self._run("send-keys", "-t", f"{self.session}:{repo}", shell_join(command), "C-m") self._run("select-window", "-t", f"{self.session}:{plan.repos[0]}") return Endpoint( self.session, pid, list(plan.repos), instance_id, created_session, tuple(created_windows), ) except Exception: self.rollback(Endpoint(self.session, 0, list(plan.repos), created_session=created_session, created_windows=tuple(created_windows))) raise def rollback(self, endpoint: Endpoint) -> None: if endpoint.created_session: self._run("kill-session", "-t", endpoint.session, check=False) return for window in reversed(endpoint.created_windows): self._run("kill-window", "-t", f"{endpoint.session}:{window}", check=False) def shell_join(parts: list[str]) -> str: return " ".join(shlex.quote(part) for part in parts)