|
| 1 | +"""Project filters keep linked worktrees and picker refreshes consistent.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import argparse |
| 6 | +import subprocess |
| 7 | +from pathlib import Path |
| 8 | + |
| 9 | +import pytest |
| 10 | + |
| 11 | +from claude_code_tools.amux import cli |
| 12 | +from claude_code_tools.amux.filters import directory_argument, select_agents |
| 13 | +from claude_code_tools.amux.model import Agent |
| 14 | + |
| 15 | + |
| 16 | +def agent(cwd: Path, repo: str = "", pane: str = "test:1.1") -> Agent: |
| 17 | + """Make a live-row fixture without touching agent processes.""" |
| 18 | + return Agent(pane, "test", "claude", cwd=str(cwd), repo=repo) |
| 19 | + |
| 20 | + |
| 21 | +def test_repository_filter_includes_linked_worktree(tmp_path: Path) -> None: |
| 22 | + repo = tmp_path / "project" |
| 23 | + linked = tmp_path / "feature-tree" |
| 24 | + subprocess.run(["git", "init", "-q", str(repo)], check=True) |
| 25 | + subprocess.run([ |
| 26 | + "git", "-C", str(repo), "-c", "user.name=Test", |
| 27 | + "-c", "user.email=test@example.invalid", "commit", "-qm", "initial", |
| 28 | + "--allow-empty", |
| 29 | + ], check=True) |
| 30 | + subprocess.run([ |
| 31 | + "git", "-C", str(repo), "worktree", "add", "-qb", "feature", str(linked), |
| 32 | + ], check=True) |
| 33 | + (linked / "nested").mkdir() |
| 34 | + rows = [agent(repo, "project"), agent(linked / "nested", "feature-tree"), |
| 35 | + agent(tmp_path / "unrelated", "project-other")] |
| 36 | + assert select_agents(rows, "project", None) == rows[:2] |
| 37 | + assert select_agents(rows, "feature-tree", None) == [rows[1]] |
| 38 | + assert select_agents(rows, "Project", None) == [] |
| 39 | + |
| 40 | + |
| 41 | +def test_directory_boundary_symlink_and_combination(tmp_path: Path) -> None: |
| 42 | + repo = tmp_path / "project" |
| 43 | + (repo / "nested").mkdir(parents=True) |
| 44 | + sibling = tmp_path / "project-other" |
| 45 | + sibling.mkdir() |
| 46 | + alias = tmp_path / "alias" |
| 47 | + alias.symlink_to(repo, target_is_directory=True) |
| 48 | + rows = [agent(repo, "project"), agent(repo / "nested", "project"), |
| 49 | + agent(sibling, "project"), agent(repo, "other"), |
| 50 | + Agent("test:1.9", "test", "claude", repo="project")] |
| 51 | + selected = select_agents(rows, "project", directory_argument(str(alias))) |
| 52 | + assert selected == rows[:2] |
| 53 | + assert select_agents(rows, None, str(repo)) == rows[:2] + [rows[3]] |
| 54 | + |
| 55 | + |
| 56 | +def test_directory_argument_rejects_file_or_missing(tmp_path: Path) -> None: |
| 57 | + regular = tmp_path / "file" |
| 58 | + regular.write_text("content") |
| 59 | + for path in [regular, tmp_path / "missing"]: |
| 60 | + with pytest.raises(argparse.ArgumentTypeError): |
| 61 | + directory_argument(str(path)) |
| 62 | + |
| 63 | + |
| 64 | +def test_directory_argument_expands_relative_path( |
| 65 | + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, |
| 66 | +) -> None: |
| 67 | + monkeypatch.chdir(tmp_path) |
| 68 | + assert directory_argument(".") == str(tmp_path.resolve()) |
| 69 | + |
| 70 | + |
| 71 | +@pytest.mark.parametrize("command", ["list", "pick", "rows"]) |
| 72 | +def test_filters_combine_with_dormancy(command: str, tmp_path: Path) -> None: |
| 73 | + args = cli.build_parser().parse_args([ |
| 74 | + command, "--project", "project", "--dir", str(tmp_path), |
| 75 | + "--dormant", "--sort", "oldest", |
| 76 | + ]) |
| 77 | + old = agent(tmp_path, "project") |
| 78 | + old.state, old.last_input_at = "idle", 1 |
| 79 | + busy = agent(tmp_path, "project", "test:1.2") |
| 80 | + busy.state, busy.last_input_at = "busy", 1 |
| 81 | + other = agent(tmp_path, "other", "test:1.3") |
| 82 | + other.state, other.last_input_at = "idle", 1 |
| 83 | + assert cli._display([busy, other, old], args) == [old] |
| 84 | + |
| 85 | + |
| 86 | +@pytest.mark.parametrize("name", ["list", "pick", "scan", "rows"]) |
| 87 | +def test_repo_named_like_subcommand_defaults_to_picker( |
| 88 | + name: str, monkeypatch: pytest.MonkeyPatch, |
| 89 | +) -> None: |
| 90 | + seen = [] |
| 91 | + monkeypatch.setattr(cli.scan, "tmux_available", lambda: True) |
| 92 | + monkeypatch.setattr(cli, "cmd_pick", lambda args: seen.append(args) or 0) |
| 93 | + assert cli.main(["--repo", name]) == 0 |
| 94 | + assert seen[0].repo == name |
| 95 | + |
| 96 | + |
| 97 | +def test_picker_refresh_preserves_literal_filter_values( |
| 98 | + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, |
| 99 | +) -> None: |
| 100 | + directory = tmp_path / "dir (x), {q} $HOME ' space" |
| 101 | + directory.mkdir() |
| 102 | + repo = "repo (x), {q} $(echo bad) '" |
| 103 | + row = agent(directory, repo) |
| 104 | + captured = {} |
| 105 | + monkeypatch.setattr(cli.shutil, "which", lambda _: "/usr/bin/fzf") |
| 106 | + monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: True) |
| 107 | + monkeypatch.setattr(cli.sys.stdout, "isatty", lambda: True) |
| 108 | + monkeypatch.setattr(cli, "_agents_for_display", lambda _: ([row], True)) |
| 109 | + |
| 110 | + def run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: |
| 111 | + captured.update(command=cmd, **kwargs) |
| 112 | + return subprocess.CompletedProcess(cmd, 1, stdout="") |
| 113 | + |
| 114 | + monkeypatch.setattr(cli.subprocess, "run", run) |
| 115 | + args = cli.build_parser().parse_args([ |
| 116 | + "pick", "--repo", repo, "--dir", str(directory), |
| 117 | + ]) |
| 118 | + assert cli.cmd_pick(args) == 0 |
| 119 | + binds = [x for x in captured["command"] if "reload" in x] |
| 120 | + assert len(binds) == 2 |
| 121 | + assert all('--repo="$AMUX_RELOAD_REPO"' in x for x in binds) |
| 122 | + assert all('--dir="$AMUX_RELOAD_DIR"' in x for x in binds) |
| 123 | + assert all(repo not in x and str(directory) not in x for x in binds) |
| 124 | + assert captured["env"]["AMUX_RELOAD_REPO"] == repo |
| 125 | + assert captured["env"]["AMUX_RELOAD_DIR"] == str(directory) |
0 commit comments