Skip to content

Commit 5742e7d

Browse files
committed
feat(workspace): Add upward traversal for local workspace discovery
Add find_local_workspace_files() function that searches for .tmuxp.yaml, .tmuxp.yml, or .tmuxp.json files from a start directory upward to home. Features: - Traverses parent directories like git does for .git/ - Stops at user home directory by default (configurable) - Returns files ordered from closest to farthest - Prioritizes .yaml > .yml > .json when multiple exist Add LOCAL_WORKSPACE_FILES constant for dotfile names. Tests cover: upward traversal, multiple ancestors, format precedence, edge cases (home dir, symlinks, filesystem root).
1 parent 9f016e9 commit 5742e7d

2 files changed

Lines changed: 352 additions & 1 deletion

File tree

src/tmuxp/workspace/finders.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import logging
66
import os
7+
import pathlib
78
import typing as t
89

910
from colorama import Fore
@@ -13,8 +14,10 @@
1314

1415
logger = logging.getLogger(__name__)
1516

17+
#: Local workspace file names (dotfiles in project directories)
18+
LOCAL_WORKSPACE_FILES = [".tmuxp.yaml", ".tmuxp.yml", ".tmuxp.json"]
19+
1620
if t.TYPE_CHECKING:
17-
import pathlib
1821
from typing import TypeAlias
1922

2023
from tmuxp.types import StrPath
@@ -100,6 +103,69 @@ def in_cwd() -> list[str]:
100103
]
101104

102105

106+
def find_local_workspace_files(
107+
start_dir: pathlib.Path | str | None = None,
108+
*,
109+
stop_at_home: bool = True,
110+
) -> list[pathlib.Path]:
111+
"""Find .tmuxp.* files by traversing upward from start directory.
112+
113+
Searches the start directory and all parent directories up to (but not past):
114+
- User home directory (when stop_at_home=True)
115+
- Filesystem root
116+
117+
Parameters
118+
----------
119+
start_dir : pathlib.Path | str | None
120+
Directory to start searching from. Defaults to current working directory.
121+
stop_at_home : bool
122+
If True, stops traversal at user home directory. Default True.
123+
124+
Returns
125+
-------
126+
list[pathlib.Path]
127+
List of workspace file paths found, ordered from closest to farthest.
128+
129+
Examples
130+
--------
131+
>>> import tempfile
132+
>>> import pathlib
133+
>>> with tempfile.TemporaryDirectory() as tmpdir:
134+
... home = pathlib.Path(tmpdir)
135+
... project = home / "project"
136+
... project.mkdir()
137+
... _ = (project / ".tmuxp.yaml").write_text("session_name: test")
138+
... # Would find .tmuxp.yaml in project dir
139+
... len(find_local_workspace_files(project, stop_at_home=False)) >= 0
140+
True
141+
"""
142+
if start_dir is None:
143+
start_dir = os.getcwd()
144+
145+
current = pathlib.Path(start_dir).resolve()
146+
home = pathlib.Path.home().resolve()
147+
found: list[pathlib.Path] = []
148+
149+
while True:
150+
# Check for local workspace files in current directory
151+
for filename in LOCAL_WORKSPACE_FILES:
152+
candidate = current / filename
153+
if candidate.is_file():
154+
found.append(candidate)
155+
break # Only one per directory (first match wins: .yaml > .yml > .json)
156+
157+
# Stop conditions
158+
parent = current.parent
159+
if parent == current: # Reached filesystem root
160+
break
161+
if stop_at_home and current == home:
162+
break
163+
164+
current = parent
165+
166+
return found
167+
168+
103169
def get_workspace_dir() -> str:
104170
"""
105171
Return tmuxp workspace directory.
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
"""Tests for local workspace file discovery with upward traversal."""
2+
3+
from __future__ import annotations
4+
5+
import pathlib
6+
import typing as t
7+
8+
import pytest
9+
10+
from tmuxp.workspace.finders import LOCAL_WORKSPACE_FILES, find_local_workspace_files
11+
12+
13+
class LocalWorkspaceTestFixture(t.NamedTuple):
14+
"""Test fixture for local workspace file discovery."""
15+
16+
test_id: str
17+
files: dict[str, str] # {dir_relative_to_home: filename}
18+
start_dir: str # relative to home
19+
expected_count: int
20+
expected_paths: list[str] # relative to home
21+
22+
23+
LOCAL_WORKSPACE_TEST_FIXTURES: list[LocalWorkspaceTestFixture] = [
24+
LocalWorkspaceTestFixture(
25+
test_id="only_in_cwd",
26+
files={"project": ".tmuxp.yaml"},
27+
start_dir="project",
28+
expected_count=1,
29+
expected_paths=["project/.tmuxp.yaml"],
30+
),
31+
LocalWorkspaceTestFixture(
32+
test_id="only_in_parent",
33+
files={"project": ".tmuxp.yaml"},
34+
start_dir="project/subdir",
35+
expected_count=1,
36+
expected_paths=["project/.tmuxp.yaml"],
37+
),
38+
LocalWorkspaceTestFixture(
39+
test_id="in_cwd_and_parent",
40+
files={
41+
"project": ".tmuxp.yaml",
42+
"project/subdir": ".tmuxp.yaml",
43+
},
44+
start_dir="project/subdir",
45+
expected_count=2,
46+
expected_paths=["project/subdir/.tmuxp.yaml", "project/.tmuxp.yaml"],
47+
),
48+
LocalWorkspaceTestFixture(
49+
test_id="multiple_ancestors",
50+
files={
51+
"a": ".tmuxp.yaml",
52+
"a/b": ".tmuxp.yaml",
53+
"a/b/c": ".tmuxp.yaml",
54+
},
55+
start_dir="a/b/c/d",
56+
expected_count=3,
57+
expected_paths=[
58+
"a/b/c/.tmuxp.yaml",
59+
"a/b/.tmuxp.yaml",
60+
"a/.tmuxp.yaml",
61+
],
62+
),
63+
LocalWorkspaceTestFixture(
64+
test_id="no_local_files",
65+
files={},
66+
start_dir="project",
67+
expected_count=0,
68+
expected_paths=[],
69+
),
70+
LocalWorkspaceTestFixture(
71+
test_id="json_format",
72+
files={"project": ".tmuxp.json"},
73+
start_dir="project",
74+
expected_count=1,
75+
expected_paths=["project/.tmuxp.json"],
76+
),
77+
LocalWorkspaceTestFixture(
78+
test_id="yml_format",
79+
files={"project": ".tmuxp.yml"},
80+
start_dir="project",
81+
expected_count=1,
82+
expected_paths=["project/.tmuxp.yml"],
83+
),
84+
LocalWorkspaceTestFixture(
85+
test_id="stops_at_home",
86+
files={
87+
"": ".tmuxp.yaml", # In home dir itself
88+
"project": ".tmuxp.yaml",
89+
},
90+
start_dir="project",
91+
expected_count=2, # Includes home but stops there
92+
expected_paths=["project/.tmuxp.yaml", ".tmuxp.yaml"],
93+
),
94+
]
95+
96+
97+
@pytest.mark.parametrize(
98+
LocalWorkspaceTestFixture._fields,
99+
LOCAL_WORKSPACE_TEST_FIXTURES,
100+
ids=[test.test_id for test in LOCAL_WORKSPACE_TEST_FIXTURES],
101+
)
102+
def test_find_local_workspace_files(
103+
test_id: str,
104+
files: dict[str, str],
105+
start_dir: str,
106+
expected_count: int,
107+
expected_paths: list[str],
108+
tmp_path: pathlib.Path,
109+
monkeypatch: pytest.MonkeyPatch,
110+
) -> None:
111+
"""Test local workspace file discovery with upward traversal."""
112+
home = tmp_path / "home"
113+
home.mkdir()
114+
115+
# Create directory structure and files
116+
for rel_dir, filename in files.items():
117+
dir_path = home / rel_dir if rel_dir else home
118+
dir_path.mkdir(parents=True, exist_ok=True)
119+
(dir_path / filename).write_text("session_name: test\n")
120+
121+
# Ensure start directory exists
122+
start_path = home / start_dir
123+
start_path.mkdir(parents=True, exist_ok=True)
124+
125+
# Mock home directory
126+
monkeypatch.setattr(pathlib.Path, "home", lambda: home)
127+
128+
# Run the function
129+
result = find_local_workspace_files(start_path, stop_at_home=True)
130+
131+
assert len(result) == expected_count
132+
133+
# Verify paths match expected (relative to home)
134+
result_relative = [str(p.relative_to(home)) for p in result]
135+
assert result_relative == expected_paths
136+
137+
138+
class TestFindLocalWorkspaceEdgeCases:
139+
"""Edge case tests for local workspace discovery."""
140+
141+
def test_at_home_directory(
142+
self,
143+
tmp_path: pathlib.Path,
144+
monkeypatch: pytest.MonkeyPatch,
145+
) -> None:
146+
"""Test behavior when starting at home directory."""
147+
home = tmp_path / "home"
148+
home.mkdir()
149+
monkeypatch.setattr(pathlib.Path, "home", lambda: home)
150+
151+
(home / ".tmuxp.yaml").write_text("session_name: home\n")
152+
153+
result = find_local_workspace_files(home, stop_at_home=True)
154+
155+
assert len(result) == 1
156+
assert result[0] == home / ".tmuxp.yaml"
157+
158+
def test_at_filesystem_root(
159+
self,
160+
tmp_path: pathlib.Path,
161+
monkeypatch: pytest.MonkeyPatch,
162+
) -> None:
163+
"""Test traversal stops at filesystem root."""
164+
# This test verifies no infinite loop at root
165+
result = find_local_workspace_files(pathlib.Path("/"), stop_at_home=False)
166+
# Should complete without error; result depends on system state
167+
assert isinstance(result, list)
168+
169+
def test_yaml_precedence_over_json(
170+
self,
171+
tmp_path: pathlib.Path,
172+
monkeypatch: pytest.MonkeyPatch,
173+
) -> None:
174+
"""Test .yaml is preferred when multiple formats exist."""
175+
home = tmp_path / "home"
176+
project = home / "project"
177+
project.mkdir(parents=True)
178+
monkeypatch.setattr(pathlib.Path, "home", lambda: home)
179+
180+
# Create both formats
181+
(project / ".tmuxp.yaml").write_text("session_name: yaml\n")
182+
(project / ".tmuxp.json").write_text('{"session_name": "json"}')
183+
184+
result = find_local_workspace_files(project, stop_at_home=True)
185+
186+
assert len(result) == 1
187+
assert result[0].name == ".tmuxp.yaml"
188+
189+
def test_yml_precedence_over_json(
190+
self,
191+
tmp_path: pathlib.Path,
192+
monkeypatch: pytest.MonkeyPatch,
193+
) -> None:
194+
"""Test .yml is preferred when .yaml not present but .json exists."""
195+
home = tmp_path / "home"
196+
project = home / "project"
197+
project.mkdir(parents=True)
198+
monkeypatch.setattr(pathlib.Path, "home", lambda: home)
199+
200+
# Create yml and json (no yaml)
201+
(project / ".tmuxp.yml").write_text("session_name: yml\n")
202+
(project / ".tmuxp.json").write_text('{"session_name": "json"}')
203+
204+
result = find_local_workspace_files(project, stop_at_home=True)
205+
206+
assert len(result) == 1
207+
assert result[0].name == ".tmuxp.yml"
208+
209+
def test_stop_at_home_false_continues_past_home(
210+
self,
211+
tmp_path: pathlib.Path,
212+
monkeypatch: pytest.MonkeyPatch,
213+
) -> None:
214+
"""Test stop_at_home=False continues traversal past home."""
215+
# Create structure: /grandparent/home/project
216+
grandparent = tmp_path / "grandparent"
217+
home = grandparent / "home"
218+
project = home / "project"
219+
project.mkdir(parents=True)
220+
221+
monkeypatch.setattr(pathlib.Path, "home", lambda: home)
222+
223+
# Put config in grandparent (above home)
224+
(grandparent / ".tmuxp.yaml").write_text("session_name: grandparent\n")
225+
(project / ".tmuxp.yaml").write_text("session_name: project\n")
226+
227+
# With stop_at_home=True, should only find project config
228+
result_stop = find_local_workspace_files(project, stop_at_home=True)
229+
assert len(result_stop) == 1
230+
assert "project" in str(result_stop[0])
231+
232+
# With stop_at_home=False, should find both
233+
result_continue = find_local_workspace_files(project, stop_at_home=False)
234+
assert len(result_continue) >= 2
235+
236+
def test_default_start_dir_uses_cwd(
237+
self,
238+
tmp_path: pathlib.Path,
239+
monkeypatch: pytest.MonkeyPatch,
240+
) -> None:
241+
"""Test that None start_dir uses current working directory."""
242+
home = tmp_path / "home"
243+
project = home / "project"
244+
project.mkdir(parents=True)
245+
monkeypatch.setattr(pathlib.Path, "home", lambda: home)
246+
monkeypatch.chdir(project)
247+
248+
(project / ".tmuxp.yaml").write_text("session_name: cwd\n")
249+
250+
result = find_local_workspace_files(None, stop_at_home=True)
251+
252+
assert len(result) == 1
253+
assert result[0] == project / ".tmuxp.yaml"
254+
255+
def test_symlinked_directory(
256+
self,
257+
tmp_path: pathlib.Path,
258+
monkeypatch: pytest.MonkeyPatch,
259+
) -> None:
260+
"""Test behavior with symlinked directories."""
261+
home = tmp_path / "home"
262+
real_project = home / "real_project"
263+
real_project.mkdir(parents=True)
264+
symlink_project = home / "symlink_project"
265+
symlink_project.symlink_to(real_project)
266+
monkeypatch.setattr(pathlib.Path, "home", lambda: home)
267+
268+
(real_project / ".tmuxp.yaml").write_text("session_name: test\n")
269+
270+
result = find_local_workspace_files(symlink_project, stop_at_home=True)
271+
272+
assert len(result) == 1
273+
274+
275+
class TestLocalWorkspaceFilesConstant:
276+
"""Tests for LOCAL_WORKSPACE_FILES constant."""
277+
278+
def test_constant_order(self) -> None:
279+
"""Verify LOCAL_WORKSPACE_FILES has correct order (yaml, yml, json)."""
280+
assert LOCAL_WORKSPACE_FILES == [".tmuxp.yaml", ".tmuxp.yml", ".tmuxp.json"]
281+
282+
def test_constant_is_list(self) -> None:
283+
"""Verify LOCAL_WORKSPACE_FILES is a list."""
284+
assert isinstance(LOCAL_WORKSPACE_FILES, list)
285+
assert len(LOCAL_WORKSPACE_FILES) == 3

0 commit comments

Comments
 (0)