tmux observer
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Guard install-service against cross-repo overwrite

Collapse the old deploy, upgrade, and uninstall verbs into a guarded
install-service / uninstall-service pair so one clone cannot silently
replace another clone's pipx-managed solstone-tmux entrypoint.

Track install ownership with ~/.config/solstone-tmux/.install-source so
a second checkout can detect cross-repo contamination before rewriting
the pipx alias or systemd unit. This mirrors the in-progress
solstone-linux hygiene work and intentionally duplicates the pattern in
this repo's own code.

Keep the UX explicit by removing the install-service --force escape
hatch. Ambiguous states now require manual disambiguation, and make
uninstall becomes a refusal target that points users at
uninstall-service or clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+550 -46
+2 -1
AGENTS.md
··· 41 41 make format # Auto-format and lint with ruff 42 42 make ci # Full CI: format check + lint + tests 43 43 make clean # Remove build artifacts and caches 44 - make uninstall # Remove venv and all artifacts 44 + make install-service # Smart install or upgrade of the systemd service (guard-checked) 45 + make uninstall-service # Remove the installed service and pipx package (guard-checked) 45 46 make clean-install # Clean everything and reinstall from scratch 46 47 ``` 47 48
+4 -4
INSTALL.md
··· 19 19 20 20 ## install 21 21 22 - 1. **clone and deploy.** 22 + 1. **clone and install the service.** 23 23 ``` 24 24 git clone https://github.com/solpbc/solstone-tmux.git solstone-tmux 25 25 cd solstone-tmux 26 - make deploy 26 + make install-service 27 27 ``` 28 28 this installs the `solstone-tmux` command via pipx and sets up the systemd user unit. 29 29 ··· 42 42 ## updating after a code change 43 43 44 44 ``` 45 - git pull && make upgrade 45 + git pull && make install-service 46 46 ``` 47 47 48 - `make upgrade` runs `make ci` first; if tests fail, the upgrade aborts before touching the installed service. 48 + `make install-service` skips CI for a fresh install, but runs `make ci` before upgrading an existing owned install. if tests fail, the upgrade aborts before touching the installed service. 49 49 50 50 ## optional cache retention 51 51
+28 -25
Makefile
··· 1 1 # solstone-tmux Makefile 2 2 # Standalone tmux terminal observer for solstone 3 3 4 - .PHONY: install test test-only format ci clean clean-install uninstall deploy upgrade service-restart service-status service-logs uninstall-service 4 + .PHONY: install test test-only format ci clean clean-install uninstall install-service service-restart service-status service-logs uninstall-service 5 5 6 6 # Service deployment 7 7 APP := solstone-tmux ··· 76 76 @echo "" 77 77 @echo "All CI checks passed!" 78 78 79 - deploy: 80 - @command -v pipx >/dev/null 2>&1 || { echo "pipx not found. Install with: sudo dnf install pipx (or apt install pipx)"; exit 1; } 81 - @echo "==> Installing $(APP) with pipx" 82 - pipx install --force $(PIPX_FLAGS) . 83 - # Never use editable pipx installs here — they couple the running service 84 - # to the working tree; `git checkout` silently downgrades the deployed version. 85 - @echo "==> Installing systemd user unit" 86 - $(APP) install-service 87 - @echo "==> Service status" 88 - systemctl --user --no-pager status $(UNIT) | head 89 - 90 - upgrade: ci 91 - @command -v pipx >/dev/null 2>&1 || { echo "pipx not found. Install with: sudo dnf install pipx (or apt install pipx)"; exit 1; } 92 - @echo "==> Upgrading $(APP) with pipx" 93 - pipx install --force $(PIPX_FLAGS) . 94 - @echo "==> Reloading systemd and restarting $(UNIT)" 95 - systemctl --user daemon-reload 96 - systemctl --user restart $(UNIT) 79 + install-service: .installed 80 + @set -e; \ 81 + command -v pipx >/dev/null 2>&1 || { echo "pipx not found. Install with: sudo dnf install pipx (or apt install pipx)"; exit 1; }; \ 82 + mode="$$($(PYTHON) -m solstone_tmux.install_guard install)"; \ 83 + echo "$$mode"; \ 84 + case "$$mode" in \ 85 + *"fresh install"*) ;; \ 86 + *) $(MAKE) ci ;; \ 87 + esac; \ 88 + echo "==> Installing $(APP) with pipx"; \ 89 + pipx install --force $(PIPX_FLAGS) .; \ 90 + $(PYTHON) -m solstone_tmux.install_guard write-marker --repo-root "$(CURDIR)"; \ 91 + echo "==> Installing systemd user unit"; \ 92 + PATH="$$HOME/.local/bin:$$PATH" $(APP) install-service; \ 93 + echo "==> Service status"; \ 97 94 systemctl --user --no-pager status $(UNIT) | head 98 95 99 96 service-restart: ··· 105 102 service-logs: 106 103 journalctl --user -u $(APP) -n 100 --no-pager -f 107 104 108 - uninstall-service: 105 + uninstall-service: .installed 106 + $(PYTHON) -m solstone_tmux.install_guard uninstall 109 107 -systemctl --user disable --now $(UNIT) 110 108 -rm -f $$HOME/.config/systemd/user/$(UNIT) 111 109 -systemctl --user daemon-reload 112 110 -pipx uninstall $(APP) 111 + $(PYTHON) -m solstone_tmux.install_guard remove-marker 113 112 114 113 # Clean build artifacts and caches 115 114 clean: ··· 120 119 find . -type f -name "*.pyc" -delete 2>/dev/null || true 121 120 rm -f .installed 122 121 123 - # Remove venv and all artifacts 124 - uninstall: clean 125 - @echo "Removing virtual environment..." 126 - rm -rf $(VENV) 122 + uninstall: 123 + @echo "ERROR: 'make uninstall' is ambiguous." 124 + @echo " Run 'make uninstall-service' to remove the installed service and pipx package." 125 + @echo " Run 'make clean' to remove build artifacts and the dev venv." 126 + @exit 1 127 127 128 128 # Clean everything and reinstall 129 - clean-install: uninstall install 129 + clean-install: clean 130 + @echo "Removing virtual environment..." 131 + rm -rf $(VENV) 132 + @$(MAKE) install
+5 -15
src/solstone_tmux/cli.py
··· 160 160 ) 161 161 unit_content = template.replace("{BINARY}", binary).replace("{PATH}", service_path) 162 162 unit_bytes = unit_content.encode() 163 - if unit_path.exists() and unit_path.read_bytes() == unit_bytes and not args.force: 164 - print("Unit unchanged; nothing to do") 165 - return 0 166 - 167 163 unit_path.write_bytes(unit_bytes) 168 164 print(f"Wrote {unit_path}") 169 165 ··· 174 170 ["systemctl", "--user", "enable", "--now", "solstone-tmux.service"], 175 171 check=True, 176 172 ) 177 - if args.force: 178 - subprocess.run( 179 - ["systemctl", "--user", "restart", "solstone-tmux.service"], 180 - check=True, 181 - ) 173 + subprocess.run( 174 + ["systemctl", "--user", "restart", "solstone-tmux.service"], 175 + check=True, 176 + ) 182 177 print("Service enabled and started.") 183 178 subprocess.run( 184 179 ["systemctl", "--user", "status", "solstone-tmux.service"], ··· 294 289 subparsers.add_parser("setup", help="Interactive configuration") 295 290 296 291 # install-service 297 - install_parser = subparsers.add_parser( 298 - "install-service", help="Install systemd user service" 299 - ) 300 - install_parser.add_argument( 301 - "--force", action="store_true", help="Always rewrite unit and restart service." 302 - ) 292 + subparsers.add_parser("install-service", help="Install systemd user service") 303 293 304 294 # status 305 295 subparsers.add_parser("status", help="Show capture and sync state")
+158
src/solstone_tmux/install_guard.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + from __future__ import annotations 4 + 5 + import argparse 6 + import enum 7 + import os 8 + import sys 9 + from pathlib import Path 10 + 11 + APP_NAME = "solstone-tmux" 12 + MARKER_PATH = Path.home() / ".config" / "solstone-tmux" / ".install-source" 13 + PIPX_BIN_PATH = Path.home() / ".local" / "bin" / APP_NAME 14 + 15 + 16 + class State(enum.Enum): 17 + ABSENT = "ABSENT" 18 + OWNED = "OWNED" 19 + PARTIAL_OWNED = "PARTIAL_OWNED" 20 + CROSS_REPO = "CROSS_REPO" 21 + UNKNOWN = "UNKNOWN" 22 + MALFORMED = "MALFORMED" 23 + 24 + 25 + def _read_marker() -> tuple[State | None, Path | None]: 26 + if not MARKER_PATH.exists(): 27 + return State.ABSENT, None 28 + 29 + lines = MARKER_PATH.read_text(encoding="utf-8").splitlines() 30 + if len(lines) != 1: 31 + return State.MALFORMED, None 32 + 33 + marker_path = Path(lines[0]) 34 + if not marker_path.is_absolute(): 35 + return State.MALFORMED, None 36 + 37 + return None, marker_path 38 + 39 + 40 + def detect_state(repo_root: Path) -> tuple[State, Path | None]: 41 + marker_state, marker_path = _read_marker() 42 + repo_root = repo_root.resolve() 43 + bin_exists = PIPX_BIN_PATH.exists() 44 + 45 + if marker_state == State.MALFORMED: 46 + return State.MALFORMED, None 47 + 48 + if marker_state == State.ABSENT: 49 + if bin_exists: 50 + return State.UNKNOWN, None 51 + return State.ABSENT, None 52 + 53 + assert marker_path is not None 54 + resolved_marker = marker_path.resolve() 55 + if resolved_marker != repo_root: 56 + return State.CROSS_REPO, resolved_marker 57 + 58 + if bin_exists: 59 + return State.OWNED, None 60 + return State.PARTIAL_OWNED, None 61 + 62 + 63 + def write_marker(repo_root: Path) -> None: 64 + MARKER_PATH.parent.mkdir(parents=True, exist_ok=True, mode=0o755) 65 + tmp_path = MARKER_PATH.with_name(f"{MARKER_PATH.name}.tmp") 66 + tmp_path.write_text(f"{repo_root.resolve()}\n", encoding="utf-8") 67 + os.rename(tmp_path, MARKER_PATH) 68 + 69 + 70 + def remove_marker() -> None: 71 + MARKER_PATH.unlink(missing_ok=True) 72 + 73 + 74 + def _print_refusal(state: State, repo_root: Path, stale_path: Path | None) -> None: 75 + installed = "unknown" 76 + if state == State.UNKNOWN: 77 + installed = ( 78 + "unknown (no .install-source marker \u2014 likely pre-hygiene install)" 79 + ) 80 + elif state == State.MALFORMED: 81 + installed = "unknown (malformed marker)" 82 + elif stale_path is not None: 83 + installed = str(stale_path) 84 + 85 + print("mode: aborted \u2014 cross-repo contamination", file=sys.stderr) 86 + print( 87 + f"ERROR: Another {APP_NAME} install owns ~/.local/bin/{APP_NAME}.", 88 + file=sys.stderr, 89 + ) 90 + print(f" this repo: {repo_root.resolve()}", file=sys.stderr) 91 + print(f" installed: {installed}", file=sys.stderr) 92 + print( 93 + "Run `make uninstall-service` from the installed repo first,", 94 + file=sys.stderr, 95 + ) 96 + print( 97 + "or manually remove the pipx package and ~/.config/solstone-tmux/ if that repo is gone. No --force available.", 98 + file=sys.stderr, 99 + ) 100 + 101 + 102 + def main(argv: list[str] | None = None) -> int: 103 + parser = argparse.ArgumentParser( 104 + description="Guard solstone-tmux installs by repo." 105 + ) 106 + subparsers = parser.add_subparsers(dest="command", required=True) 107 + 108 + subparsers.add_parser("check", help="Print install ownership state") 109 + subparsers.add_parser("install", help="Validate install ownership") 110 + subparsers.add_parser("uninstall", help="Validate uninstall ownership") 111 + 112 + write_parser = subparsers.add_parser("write-marker", help="Write ownership marker") 113 + write_parser.add_argument("--repo-root", type=Path, required=True) 114 + 115 + subparsers.add_parser("remove-marker", help="Remove ownership marker") 116 + 117 + args = parser.parse_args(argv) 118 + repo_root = Path.cwd() 119 + 120 + if args.command == "check": 121 + state, _stale_path = detect_state(repo_root) 122 + print(state.name) 123 + return 0 124 + 125 + if args.command == "install": 126 + state, stale_path = detect_state(repo_root) 127 + if state == State.ABSENT: 128 + print("mode: fresh install") 129 + return 0 130 + if state == State.OWNED: 131 + print("mode: upgrade") 132 + return 0 133 + if state == State.PARTIAL_OWNED: 134 + print("mode: upgrade (repair)") 135 + return 0 136 + _print_refusal(state, repo_root, stale_path) 137 + return 2 138 + 139 + if args.command == "uninstall": 140 + state, stale_path = detect_state(repo_root) 141 + if state == State.ABSENT: 142 + print("no artifacts to remove") 143 + return 0 144 + if state in {State.OWNED, State.PARTIAL_OWNED}: 145 + return 0 146 + _print_refusal(state, repo_root, stale_path) 147 + return 2 148 + 149 + if args.command == "write-marker": 150 + write_marker(args.repo_root) 151 + return 0 152 + 153 + remove_marker() 154 + return 0 155 + 156 + 157 + if __name__ == "__main__": 158 + sys.exit(main())
+1 -1
tests/test_cli.py
··· 25 25 monkeypatch.delenv("PATH", raising=False) 26 26 27 27 with patch("solstone_tmux.cli.subprocess.run"): 28 - cmd_install_service(argparse.Namespace(force=False)) 28 + cmd_install_service(argparse.Namespace()) 29 29 30 30 unit_path = tmp_path / ".config" / "systemd" / "user" / "solstone-tmux.service" 31 31 return unit_path.read_text()
+352
tests/test_install_guard.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from pathlib import Path 5 + 6 + import pytest 7 + 8 + from solstone_tmux import install_guard 9 + 10 + 11 + def _patch_paths(monkeypatch, tmp_path): 12 + marker_path = tmp_path / ".config" / "solstone-tmux" / ".install-source" 13 + pipx_bin_path = tmp_path / ".local" / "bin" / "solstone-tmux" 14 + monkeypatch.setattr(install_guard, "MARKER_PATH", marker_path) 15 + monkeypatch.setattr(install_guard, "PIPX_BIN_PATH", pipx_bin_path) 16 + return marker_path, pipx_bin_path 17 + 18 + 19 + def test_absent_when_nothing_exists(tmp_path, monkeypatch): 20 + marker_path, pipx_bin_path = _patch_paths(monkeypatch, tmp_path) 21 + 22 + state, stale_path = install_guard.detect_state(tmp_path / "repo") 23 + 24 + assert marker_path.exists() is False 25 + assert pipx_bin_path.exists() is False 26 + assert state == install_guard.State.ABSENT 27 + assert stale_path is None 28 + 29 + 30 + def test_owned_when_marker_and_bin_present(tmp_path, monkeypatch): 31 + marker_path, pipx_bin_path = _patch_paths(monkeypatch, tmp_path) 32 + repo_root = tmp_path / "repo" 33 + repo_root.mkdir() 34 + pipx_bin_path.parent.mkdir(parents=True) 35 + pipx_bin_path.touch() 36 + marker_path.parent.mkdir(parents=True) 37 + marker_path.write_text(f"{repo_root.resolve()}\n", encoding="utf-8") 38 + 39 + state, stale_path = install_guard.detect_state(repo_root) 40 + 41 + assert state == install_guard.State.OWNED 42 + assert stale_path is None 43 + 44 + 45 + def test_partial_owned_when_marker_present_but_no_bin(tmp_path, monkeypatch): 46 + marker_path, _pipx_bin_path = _patch_paths(monkeypatch, tmp_path) 47 + repo_root = tmp_path / "repo" 48 + repo_root.mkdir() 49 + marker_path.parent.mkdir(parents=True) 50 + marker_path.write_text(f"{repo_root.resolve()}\n", encoding="utf-8") 51 + 52 + state, stale_path = install_guard.detect_state(repo_root) 53 + 54 + assert state == install_guard.State.PARTIAL_OWNED 55 + assert stale_path is None 56 + 57 + 58 + def test_cross_repo_when_marker_points_elsewhere(tmp_path, monkeypatch): 59 + marker_path, pipx_bin_path = _patch_paths(monkeypatch, tmp_path) 60 + repo_root = tmp_path / "repo" 61 + other_repo = tmp_path / "other-repo" 62 + repo_root.mkdir() 63 + other_repo.mkdir() 64 + pipx_bin_path.parent.mkdir(parents=True) 65 + pipx_bin_path.touch() 66 + marker_path.parent.mkdir(parents=True) 67 + marker_path.write_text(f"{other_repo.resolve()}\n", encoding="utf-8") 68 + 69 + state, stale_path = install_guard.detect_state(repo_root) 70 + 71 + assert state == install_guard.State.CROSS_REPO 72 + assert stale_path == other_repo.resolve() 73 + 74 + 75 + def test_cross_repo_when_marker_points_to_deleted_path(tmp_path, monkeypatch): 76 + marker_path, pipx_bin_path = _patch_paths(monkeypatch, tmp_path) 77 + repo_root = tmp_path / "repo" 78 + deleted_repo = tmp_path / "gone" / "repo" 79 + repo_root.mkdir() 80 + pipx_bin_path.parent.mkdir(parents=True) 81 + pipx_bin_path.touch() 82 + marker_path.parent.mkdir(parents=True) 83 + marker_path.write_text(f"{deleted_repo}\n", encoding="utf-8") 84 + 85 + state, stale_path = install_guard.detect_state(repo_root) 86 + 87 + assert state == install_guard.State.CROSS_REPO 88 + assert stale_path == deleted_repo.resolve() 89 + 90 + 91 + def test_unknown_when_no_marker_but_bin_present(tmp_path, monkeypatch): 92 + _marker_path, pipx_bin_path = _patch_paths(monkeypatch, tmp_path) 93 + pipx_bin_path.parent.mkdir(parents=True) 94 + pipx_bin_path.touch() 95 + 96 + state, stale_path = install_guard.detect_state(tmp_path / "repo") 97 + 98 + assert state == install_guard.State.UNKNOWN 99 + assert stale_path is None 100 + 101 + 102 + def test_malformed_multiline(tmp_path, monkeypatch): 103 + marker_path, _pipx_bin_path = _patch_paths(monkeypatch, tmp_path) 104 + marker_path.parent.mkdir(parents=True) 105 + marker_path.write_text("/tmp/one\n/tmp/two\n", encoding="utf-8") 106 + 107 + state, stale_path = install_guard.detect_state(tmp_path / "repo") 108 + 109 + assert state == install_guard.State.MALFORMED 110 + assert stale_path is None 111 + 112 + 113 + def test_malformed_relative_path(tmp_path, monkeypatch): 114 + marker_path, _pipx_bin_path = _patch_paths(monkeypatch, tmp_path) 115 + marker_path.parent.mkdir(parents=True) 116 + marker_path.write_text("relative/path\n", encoding="utf-8") 117 + 118 + state, stale_path = install_guard.detect_state(tmp_path / "repo") 119 + 120 + assert state == install_guard.State.MALFORMED 121 + assert stale_path is None 122 + 123 + 124 + def test_malformed_empty_file(tmp_path, monkeypatch): 125 + marker_path, _pipx_bin_path = _patch_paths(monkeypatch, tmp_path) 126 + marker_path.parent.mkdir(parents=True) 127 + marker_path.write_text("", encoding="utf-8") 128 + 129 + state, stale_path = install_guard.detect_state(tmp_path / "repo") 130 + 131 + assert state == install_guard.State.MALFORMED 132 + assert stale_path is None 133 + 134 + 135 + def test_write_marker_creates_dir_and_writes_resolved_path_newline_terminated( 136 + tmp_path, monkeypatch 137 + ): 138 + marker_path, _pipx_bin_path = _patch_paths(monkeypatch, tmp_path) 139 + (tmp_path / "repo").mkdir() 140 + repo_root = tmp_path / "repo" / ".." / "repo" 141 + 142 + install_guard.write_marker(repo_root) 143 + 144 + assert marker_path.read_text(encoding="utf-8") == f"{repo_root.resolve()}\n" 145 + 146 + 147 + def test_write_marker_is_atomic(tmp_path, monkeypatch): 148 + marker_path, _pipx_bin_path = _patch_paths(monkeypatch, tmp_path) 149 + repo_root = tmp_path / "repo" 150 + repo_root.mkdir() 151 + marker_path.parent.mkdir(parents=True) 152 + marker_path.write_text("old\n", encoding="utf-8") 153 + rename_calls = [] 154 + original_rename = install_guard.os.rename 155 + 156 + def record_rename(src, dst): 157 + rename_calls.append((Path(src), Path(dst))) 158 + original_rename(src, dst) 159 + 160 + monkeypatch.setattr(install_guard.os, "rename", record_rename) 161 + 162 + install_guard.write_marker(repo_root) 163 + 164 + assert marker_path.read_text(encoding="utf-8") == f"{repo_root.resolve()}\n" 165 + assert rename_calls == [ 166 + (marker_path.with_name(f"{marker_path.name}.tmp"), marker_path) 167 + ] 168 + 169 + 170 + def test_remove_marker_idempotent_when_absent(tmp_path, monkeypatch): 171 + marker_path, _pipx_bin_path = _patch_paths(monkeypatch, tmp_path) 172 + 173 + install_guard.remove_marker() 174 + install_guard.remove_marker() 175 + 176 + assert marker_path.exists() is False 177 + 178 + 179 + def test_main_check_prints_state_name_exits_zero(tmp_path, monkeypatch, capsys): 180 + marker_path, _pipx_bin_path = _patch_paths(monkeypatch, tmp_path) 181 + repo_root = tmp_path / "repo" 182 + repo_root.mkdir() 183 + marker_path.parent.mkdir(parents=True) 184 + marker_path.write_text(f"{repo_root.resolve()}\n", encoding="utf-8") 185 + monkeypatch.chdir(repo_root) 186 + 187 + result = install_guard.main(["check"]) 188 + 189 + captured = capsys.readouterr() 190 + assert result == 0 191 + assert captured.out.strip() == "PARTIAL_OWNED" 192 + 193 + 194 + def test_main_install_fresh_prints_mode_fresh_install_exits_zero( 195 + tmp_path, monkeypatch, capsys 196 + ): 197 + _marker_path, _pipx_bin_path = _patch_paths(monkeypatch, tmp_path) 198 + repo_root = tmp_path / "repo" 199 + repo_root.mkdir() 200 + monkeypatch.chdir(repo_root) 201 + 202 + result = install_guard.main(["install"]) 203 + 204 + captured = capsys.readouterr() 205 + assert result == 0 206 + assert captured.out.strip() == "mode: fresh install" 207 + 208 + 209 + def test_main_install_owned_prints_mode_upgrade_exits_zero( 210 + tmp_path, monkeypatch, capsys 211 + ): 212 + marker_path, pipx_bin_path = _patch_paths(monkeypatch, tmp_path) 213 + repo_root = tmp_path / "repo" 214 + repo_root.mkdir() 215 + marker_path.parent.mkdir(parents=True) 216 + marker_path.write_text(f"{repo_root.resolve()}\n", encoding="utf-8") 217 + pipx_bin_path.parent.mkdir(parents=True) 218 + pipx_bin_path.touch() 219 + monkeypatch.chdir(repo_root) 220 + 221 + result = install_guard.main(["install"]) 222 + 223 + captured = capsys.readouterr() 224 + assert result == 0 225 + assert captured.out.strip() == "mode: upgrade" 226 + 227 + 228 + def test_main_install_partial_owned_prints_mode_upgrade_repair_exits_zero( 229 + tmp_path, monkeypatch, capsys 230 + ): 231 + marker_path, _pipx_bin_path = _patch_paths(monkeypatch, tmp_path) 232 + repo_root = tmp_path / "repo" 233 + repo_root.mkdir() 234 + marker_path.parent.mkdir(parents=True) 235 + marker_path.write_text(f"{repo_root.resolve()}\n", encoding="utf-8") 236 + monkeypatch.chdir(repo_root) 237 + 238 + result = install_guard.main(["install"]) 239 + 240 + captured = capsys.readouterr() 241 + assert result == 0 242 + assert captured.out.strip() == "mode: upgrade (repair)" 243 + 244 + 245 + def test_main_install_cross_repo_prints_error_exits_two(tmp_path, monkeypatch, capsys): 246 + marker_path, pipx_bin_path = _patch_paths(monkeypatch, tmp_path) 247 + repo_root = tmp_path / "repo" 248 + other_repo = tmp_path / "other-repo" 249 + repo_root.mkdir() 250 + other_repo.mkdir() 251 + marker_path.parent.mkdir(parents=True) 252 + marker_path.write_text(f"{other_repo.resolve()}\n", encoding="utf-8") 253 + pipx_bin_path.parent.mkdir(parents=True) 254 + pipx_bin_path.touch() 255 + monkeypatch.chdir(repo_root) 256 + 257 + result = install_guard.main(["install"]) 258 + 259 + captured = capsys.readouterr() 260 + assert result == 2 261 + assert captured.out == "" 262 + assert "mode: aborted" in captured.err 263 + assert str(other_repo.resolve()) in captured.err 264 + 265 + 266 + def test_main_install_unknown_exits_two(tmp_path, monkeypatch, capsys): 267 + _marker_path, pipx_bin_path = _patch_paths(monkeypatch, tmp_path) 268 + repo_root = tmp_path / "repo" 269 + repo_root.mkdir() 270 + pipx_bin_path.parent.mkdir(parents=True) 271 + pipx_bin_path.touch() 272 + monkeypatch.chdir(repo_root) 273 + 274 + result = install_guard.main(["install"]) 275 + 276 + captured = capsys.readouterr() 277 + assert result == 2 278 + assert "pre-hygiene install" in captured.err 279 + 280 + 281 + def test_main_install_malformed_exits_two(tmp_path, monkeypatch, capsys): 282 + marker_path, _pipx_bin_path = _patch_paths(monkeypatch, tmp_path) 283 + repo_root = tmp_path / "repo" 284 + repo_root.mkdir() 285 + marker_path.parent.mkdir(parents=True) 286 + marker_path.write_text("relative/path\n", encoding="utf-8") 287 + monkeypatch.chdir(repo_root) 288 + 289 + result = install_guard.main(["install"]) 290 + 291 + captured = capsys.readouterr() 292 + assert result == 2 293 + assert "malformed marker" in captured.err 294 + 295 + 296 + def test_main_uninstall_absent_prints_no_artifacts_exits_zero( 297 + tmp_path, monkeypatch, capsys 298 + ): 299 + _marker_path, _pipx_bin_path = _patch_paths(monkeypatch, tmp_path) 300 + repo_root = tmp_path / "repo" 301 + repo_root.mkdir() 302 + monkeypatch.chdir(repo_root) 303 + 304 + result = install_guard.main(["uninstall"]) 305 + 306 + captured = capsys.readouterr() 307 + assert result == 0 308 + assert captured.out.strip() == "no artifacts to remove" 309 + 310 + 311 + def test_main_uninstall_owned_exits_zero_silent(tmp_path, monkeypatch, capsys): 312 + marker_path, pipx_bin_path = _patch_paths(monkeypatch, tmp_path) 313 + repo_root = tmp_path / "repo" 314 + repo_root.mkdir() 315 + marker_path.parent.mkdir(parents=True) 316 + marker_path.write_text(f"{repo_root.resolve()}\n", encoding="utf-8") 317 + pipx_bin_path.parent.mkdir(parents=True) 318 + pipx_bin_path.touch() 319 + monkeypatch.chdir(repo_root) 320 + 321 + result = install_guard.main(["uninstall"]) 322 + 323 + captured = capsys.readouterr() 324 + assert result == 0 325 + assert captured.out == "" 326 + assert captured.err == "" 327 + 328 + 329 + def test_main_uninstall_cross_repo_exits_two(tmp_path, monkeypatch, capsys): 330 + marker_path, pipx_bin_path = _patch_paths(monkeypatch, tmp_path) 331 + repo_root = tmp_path / "repo" 332 + other_repo = tmp_path / "other-repo" 333 + repo_root.mkdir() 334 + other_repo.mkdir() 335 + marker_path.parent.mkdir(parents=True) 336 + marker_path.write_text(f"{other_repo.resolve()}\n", encoding="utf-8") 337 + pipx_bin_path.parent.mkdir(parents=True) 338 + pipx_bin_path.touch() 339 + monkeypatch.chdir(repo_root) 340 + 341 + result = install_guard.main(["uninstall"]) 342 + 343 + captured = capsys.readouterr() 344 + assert result == 2 345 + assert str(other_repo.resolve()) in captured.err 346 + 347 + 348 + def test_main_write_marker_subcommand_requires_repo_root_arg(): 349 + with pytest.raises(SystemExit) as excinfo: 350 + install_guard.main(["write-marker"]) 351 + 352 + assert excinfo.value.code == 2