Skip to content

Commit 65872ce

Browse files
committed
Make Workspace(path) work from inside a branch
Signed-off-by: Cong Wang <cwang@multikernel.io>
1 parent b5bb3c6 commit 65872ce

6 files changed

Lines changed: 99 additions & 22 deletions

File tree

src/branching/core/registry.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,15 @@ def get_fs(fstype: str) -> Optional[Type[FSBackend]]:
3838
return _registry.get(fstype)
3939

4040

41-
def detect_fs_for_mount(path: Path) -> Type[FSBackend]:
41+
def detect_fs_for_mount(path: Path) -> tuple[Type[FSBackend], Path]:
4242
"""
4343
Auto-detect filesystem type for a mountpoint and return its implementation.
4444
4545
Args:
46-
path: Path to mounted filesystem
46+
path: Path to mounted filesystem (may be inside a mount)
4747
4848
Returns:
49-
FSBackend subclass for the detected filesystem
49+
Tuple of (FSBackend subclass, resolved mount root path)
5050
5151
Raises:
5252
BranchingError: If no mount found or filesystem type not supported
@@ -77,7 +77,7 @@ def detect_fs_for_mount(path: Path) -> Type[FSBackend]:
7777
f"Supported types: {', '.join(_registry.keys()) or 'none'}"
7878
)
7979

80-
return fs_class
80+
return fs_class, mount_info.mountpoint.resolve()
8181

8282

8383
def list_supported() -> list[str]:

src/branching/core/workspace.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def __init__(self, path: str | Path):
4242
BranchingError: If no mount found or filesystem not supported
4343
"""
4444
self._path = Path(path).resolve()
45-
self._fs = detect_fs_for_mount(self._path)
45+
self._fs, self._mount_root = detect_fs_for_mount(self._path)
4646

4747
@property
4848
def path(self) -> Path:
@@ -77,7 +77,7 @@ def _generate_mountpoint(self, name: str) -> Path:
7777
"""
7878
if self._fs.single_mount():
7979
# BranchFS: view switches in-place, same mount root
80-
return self._path
80+
return self._mount_root
8181
else:
8282
# DaxFS: each branch gets its own mount
8383
return self._path.parent / f"{self._path.name}_{name}"
@@ -108,7 +108,7 @@ def branch(
108108
parent_branch="/main",
109109
on_success=on_success,
110110
on_error=on_error,
111-
mount_root=self._path,
111+
mount_root=self._mount_root,
112112
)
113113

114114
def __repr__(self) -> str:

src/branching/fs/_mount.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,20 +75,37 @@ def parse_mounts(path: str = PROC_MOUNTS) -> List[MountInfo]:
7575

7676
def find_mount(mountpoint: Path, fstype: Optional[str] = None) -> Optional[MountInfo]:
7777
"""
78-
Find mount info for a specific mountpoint.
78+
Find mount info for the nearest enclosing mountpoint.
79+
80+
Walks up from *mountpoint* toward the root, returning the first
81+
(nearest) mount that matches. This lets callers pass a path
82+
*inside* a mount (e.g. a branch virtual path) and still find the
83+
underlying filesystem.
7984
8085
Args:
81-
mountpoint: Path to the mountpoint
86+
mountpoint: Path to look up (exact mountpoint or path inside one)
8287
fstype: Optional filesystem type filter
8388
8489
Returns:
8590
MountInfo if found, None otherwise
8691
"""
8792
mountpoint = mountpoint.resolve()
88-
for mount in parse_mounts():
89-
if mount.mountpoint.resolve() == mountpoint:
90-
if fstype is None or mount.fstype == fstype:
91-
return mount
93+
mounts = parse_mounts()
94+
# Build map: resolved mountpoint → MountInfo (last wins for overlays)
95+
mount_map: Dict[Path, MountInfo] = {}
96+
for m in mounts:
97+
mp = m.mountpoint.resolve()
98+
if fstype is None or m.fstype == fstype:
99+
mount_map[mp] = m
100+
# Walk up from path to root, return nearest enclosing mount
101+
path = mountpoint
102+
while True:
103+
if path in mount_map:
104+
return mount_map[path]
105+
parent = path.parent
106+
if parent == path:
107+
break
108+
path = parent
92109
return None
93110

94111

tests/test_resource_limits.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,7 @@ def abort(cls, mountpoint):
407407
pass
408408

409409
with patch("branching.core.workspace.detect_fs_for_mount") as mock:
410-
mock.return_value = MockFSBackend
410+
mock.return_value = (MockFSBackend, Path("/tmp/test_ws"))
411411
return Workspace("/tmp/test_ws")
412412

413413
def test_speculate_passes_resource_limits(self):
@@ -520,7 +520,7 @@ def abort(cls, mountpoint):
520520
pass
521521

522522
with patch("branching.core.workspace.detect_fs_for_mount") as mock:
523-
mock.return_value = MockFSBackend
523+
mock.return_value = (MockFSBackend, Path("/tmp/test_ws"))
524524
return Workspace("/tmp/test_ws")
525525

526526
def test_speculate_passes_group_limits(self):

tests/test_speculate.py

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,10 @@ def reset_mock():
5757
MockFSBackend.reset()
5858

5959

60-
def _make_workspace():
60+
def _make_workspace(path="/tmp/test_ws"):
6161
with patch("branching.core.workspace.detect_fs_for_mount") as mock_detect:
62-
mock_detect.return_value = MockFSBackend
63-
return Workspace("/tmp/test_ws")
62+
mock_detect.return_value = (MockFSBackend, Path(path))
63+
return Workspace(path)
6464

6565

6666
class TestSpeculate:
@@ -1066,3 +1066,63 @@ def test_outcome_defaults(self):
10661066
assert o.winner is None
10671067
assert o.all_results == []
10681068
assert not o.committed
1069+
1070+
1071+
class SingleMountMockFSBackend(MockFSBackend):
1072+
"""Mock FS backend that uses single-mount semantics (like BranchFS)."""
1073+
1074+
@classmethod
1075+
def single_mount(cls) -> bool:
1076+
return True
1077+
1078+
1079+
class TestSubBranching:
1080+
"""Workspace(path) works when path is inside an existing mount."""
1081+
1082+
def test_workspace_from_branch_path(self):
1083+
"""Candidate constructs Workspace(branch_path) and sub-branches."""
1084+
mount_root = "/mnt/ws"
1085+
branch_path = "/mnt/ws/@uuid0"
1086+
1087+
with patch("branching.core.workspace.detect_fs_for_mount") as mock_detect:
1088+
mock_detect.return_value = (SingleMountMockFSBackend, Path(mount_root))
1089+
ws = Workspace(branch_path)
1090+
1091+
# Public API: path reflects where the candidate works
1092+
assert ws.path == Path(branch_path)
1093+
1094+
# Sub-branching works and creates branches on the real mount
1095+
b = ws.branch("sub")
1096+
assert b.name == "sub"
1097+
# create_branch receives the mount root, not the branch virtual path
1098+
with b:
1099+
assert SingleMountMockFSBackend._branches_created[-1] == "sub"
1100+
1101+
def test_workspace_at_mount_root_unchanged(self):
1102+
"""Normal usage (path == mount root) behaves the same as before."""
1103+
with patch("branching.core.workspace.detect_fs_for_mount") as mock_detect:
1104+
mock_detect.return_value = (SingleMountMockFSBackend, Path("/mnt/ws"))
1105+
ws = Workspace("/mnt/ws")
1106+
assert ws.path == Path("/mnt/ws")
1107+
1108+
b = ws.branch("feat")
1109+
assert b.name == "feat"
1110+
with b:
1111+
assert SingleMountMockFSBackend._branches_created[-1] == "feat"
1112+
1113+
def test_sub_branch_speculate(self):
1114+
"""End-to-end: candidate sub-branches via Workspace and Speculate."""
1115+
mount_root = "/mnt/ws"
1116+
branch_path = "/mnt/ws/@uuid0"
1117+
1118+
with patch("branching.core.workspace.detect_fs_for_mount") as mock_detect:
1119+
mock_detect.return_value = (SingleMountMockFSBackend, Path(mount_root))
1120+
ws = Workspace(branch_path)
1121+
1122+
def candidate(path: Path) -> bool:
1123+
return True
1124+
1125+
spec = Speculate([candidate], first_wins=True)
1126+
outcome = spec(ws)
1127+
assert outcome.committed
1128+
assert outcome.winner.success

tests/test_workspace.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,15 @@ def single_mount(cls) -> bool:
3939

4040
@patch("branching.core.workspace.detect_fs_for_mount")
4141
def test_workspace_creation(mock_detect):
42-
mock_detect.return_value = MockFSBackend
42+
mock_detect.return_value = (MockFSBackend, Path("/tmp/test_ws"))
4343
ws = Workspace("/tmp/test_ws")
4444
assert ws.path == Path("/tmp/test_ws").resolve()
4545
assert ws.fstype == "mockfs"
4646

4747

4848
@patch("branching.core.workspace.detect_fs_for_mount")
4949
def test_workspace_branch_mountpoint_generation(mock_detect):
50-
mock_detect.return_value = MockFSBackend
50+
mock_detect.return_value = (MockFSBackend, Path("/tmp/test_ws"))
5151
ws = Workspace("/tmp/test_ws")
5252
b = ws.branch("feat")
5353
# Mount-per-branch: sibling directory
@@ -56,7 +56,7 @@ def test_workspace_branch_mountpoint_generation(mock_detect):
5656

5757
@patch("branching.core.workspace.detect_fs_for_mount")
5858
def test_workspace_single_mount_branch(mock_detect):
59-
mock_detect.return_value = SingleMountMockFSBackend
59+
mock_detect.return_value = (SingleMountMockFSBackend, Path("/tmp/test_ws"))
6060
ws = Workspace("/tmp/test_ws")
6161
b = ws.branch("feat")
6262
# Single mount: same path
@@ -65,7 +65,7 @@ def test_workspace_single_mount_branch(mock_detect):
6565

6666
@patch("branching.core.workspace.detect_fs_for_mount")
6767
def test_workspace_repr(mock_detect):
68-
mock_detect.return_value = MockFSBackend
68+
mock_detect.return_value = (MockFSBackend, Path("/tmp/test_ws"))
6969
ws = Workspace("/tmp/test_ws")
7070
assert "Workspace" in repr(ws)
7171
assert "mockfs" in repr(ws)

0 commit comments

Comments
 (0)