Skip to content

Commit 6bd6ab9

Browse files
committed
Add Workspace.mount() API for programmatic branchfs setup
Signed-off-by: Cong Wang <cwang@multikernel.io>
1 parent f3a8c3d commit 6bd6ab9

2 files changed

Lines changed: 249 additions & 6 deletions

File tree

src/branching/core/workspace.py

Lines changed: 168 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,89 @@
11
# SPDX-License-Identifier: Apache-2.0
22
"""Workspace class - main user-facing API for branch management."""
33

4+
from __future__ import annotations
5+
6+
import shutil
7+
import subprocess
8+
import tempfile
9+
import time
410
from pathlib import Path
11+
from typing import Optional
512

613
from .base import FSBackend
714
from .branch import Branch, OnSuccessAction, OnErrorAction
815
from .registry import detect_fs_for_mount
16+
from ..exceptions import MountError
17+
18+
19+
class _OwnedMount:
20+
"""Manages a branchfs mount lifecycle started by Workspace.mount()."""
21+
22+
def __init__(
23+
self,
24+
base: Path,
25+
mountpoint: Optional[Path],
26+
storage: Optional[Path],
27+
binary: Path,
28+
):
29+
self.base = base
30+
self.binary = binary
31+
self._tmp_mountpoint = mountpoint is None
32+
self._tmp_storage = storage is None
33+
self.mountpoint = Path(mountpoint) if mountpoint else Path(
34+
tempfile.mkdtemp(prefix="branchfs_mnt_")
35+
)
36+
self.storage = Path(storage) if storage else Path(
37+
tempfile.mkdtemp(prefix="branchfs_storage_")
38+
)
39+
40+
def start(self) -> None:
41+
"""Run `branchfs mount` which starts the daemon and mounts."""
42+
self.mountpoint.mkdir(parents=True, exist_ok=True)
43+
self.storage.mkdir(parents=True, exist_ok=True)
44+
45+
result = subprocess.run(
46+
[
47+
str(self.binary), "mount",
48+
"--base", str(self.base),
49+
"--storage", str(self.storage),
50+
str(self.mountpoint),
51+
],
52+
capture_output=True, text=True,
53+
)
54+
if result.returncode != 0:
55+
self._cleanup_dirs()
56+
raise MountError(
57+
f"branchfs mount failed: {result.stderr.strip() or result.stdout.strip()}"
58+
)
59+
60+
# Wait for FUSE to be ready
61+
ctl = self.mountpoint / ".branchfs_ctl"
62+
for _ in range(50):
63+
if ctl.exists():
64+
return
65+
time.sleep(0.1)
66+
67+
self.stop()
68+
raise MountError("branchfs mount timed out waiting for FUSE")
69+
70+
def stop(self) -> None:
71+
"""Unmount via branchfs CLI and clean up temp dirs."""
72+
subprocess.run(
73+
[
74+
str(self.binary), "unmount",
75+
str(self.mountpoint),
76+
"--storage", str(self.storage),
77+
],
78+
capture_output=True,
79+
)
80+
self._cleanup_dirs()
81+
82+
def _cleanup_dirs(self) -> None:
83+
if self._tmp_mountpoint:
84+
shutil.rmtree(self.mountpoint, ignore_errors=True)
85+
if self._tmp_storage:
86+
shutil.rmtree(self.storage, ignore_errors=True)
987

1088

1189
class Workspace:
@@ -18,13 +96,14 @@ class Workspace:
1896
Example:
1997
from branching import Workspace
2098
21-
# Open workspace from existing mount
22-
ws = Workspace("/mnt/main")
99+
# Mount and use (handles setup and teardown)
100+
with Workspace.mount("./my_project") as ws:
101+
with ws.branch("attempt1") as b:
102+
(b.path / "result.txt").write_text("done")
103+
# auto-commits on success, auto-aborts on exception
23104
24-
# Simple speculative execution
25-
with ws.branch("attempt1") as b:
26-
subprocess.run(["agent", "--workdir", str(b.path)])
27-
# auto-commits on success, auto-aborts on exception
105+
# Or open an already-mounted workspace
106+
ws = Workspace("/mnt/main")
28107
29108
Attributes:
30109
path: Path to the main branch mountpoint
@@ -43,6 +122,71 @@ def __init__(self, path: str | Path):
43122
"""
44123
self._path = Path(path).resolve()
45124
self._fs, self._mount_root = detect_fs_for_mount(self._path)
125+
self._owned_mount: Optional[_OwnedMount] = None
126+
127+
@classmethod
128+
def mount(
129+
cls,
130+
base: str | Path,
131+
*,
132+
mountpoint: str | Path | None = None,
133+
storage: str | Path | None = None,
134+
branchfs_bin: str | Path | None = None,
135+
) -> Workspace:
136+
"""
137+
Mount a branchfs workspace and return a ready Workspace.
138+
139+
Handles daemon startup and FUSE mount automatically.
140+
Use as a context manager for automatic cleanup::
141+
142+
with Workspace.mount("./project") as ws:
143+
with ws.branch("fix") as b:
144+
...
145+
146+
Or call ``ws.close()`` manually when done.
147+
148+
Args:
149+
base: Directory to branch from (your project root).
150+
mountpoint: Where to mount FUSE. Auto-created temp dir if None.
151+
storage: Directory for daemon/branch data. Temp dir if None.
152+
branchfs_bin: Path to branchfs binary. Searches PATH if None.
153+
154+
Raises:
155+
MountError: If branchfs binary not found or mount fails.
156+
"""
157+
base = Path(base).resolve()
158+
if not base.is_dir():
159+
raise MountError(f"Base directory does not exist: {base}")
160+
161+
binary = _find_branchfs(branchfs_bin)
162+
163+
owned = _OwnedMount(
164+
base,
165+
Path(mountpoint) if mountpoint else None,
166+
Path(storage) if storage else None,
167+
binary,
168+
)
169+
owned.start()
170+
171+
ws = cls(owned.mountpoint)
172+
ws._owned_mount = owned
173+
return ws
174+
175+
def close(self) -> None:
176+
"""Unmount and clean up if this workspace was created via mount().
177+
178+
No-op if the workspace was opened from an existing mount.
179+
"""
180+
if self._owned_mount is not None:
181+
self._owned_mount.stop()
182+
self._owned_mount = None
183+
184+
def __enter__(self) -> Workspace:
185+
return self
186+
187+
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
188+
self.close()
189+
return False
46190

47191
@property
48192
def path(self) -> Path:
@@ -97,3 +241,21 @@ def branch(
97241

98242
def __repr__(self) -> str:
99243
return f"Workspace(path={self._path!r}, fstype={self.fstype!r})"
244+
245+
246+
def _find_branchfs(hint: str | Path | None) -> Path:
247+
"""Locate the branchfs binary."""
248+
if hint is not None:
249+
p = Path(hint)
250+
if p.is_file():
251+
return p
252+
raise MountError(f"branchfs binary not found at {p}")
253+
254+
found = shutil.which("branchfs")
255+
if found:
256+
return Path(found)
257+
258+
raise MountError(
259+
"branchfs binary not found in PATH. "
260+
"Install branchfs or pass branchfs_bin= to Workspace.mount()."
261+
)

tests/test_workspace_mount.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
"""Integration tests for Workspace.mount() — requires branchfs binary and FUSE."""
3+
4+
import os
5+
import shutil
6+
import tempfile
7+
from pathlib import Path
8+
9+
import pytest
10+
11+
from branching.core.workspace import Workspace, _find_branchfs
12+
from branching.exceptions import MountError
13+
14+
15+
# Skip all tests if branchfs binary not available or no FUSE
16+
pytestmark = pytest.mark.skipif(
17+
shutil.which("branchfs") is None
18+
or not (Path("/dev/fuse").exists() and os.access("/dev/fuse", os.R_OK | os.W_OK)),
19+
reason="branchfs binary not in PATH or /dev/fuse not accessible",
20+
)
21+
22+
23+
@pytest.fixture
24+
def base_dir():
25+
d = Path(tempfile.mkdtemp(prefix="branchfs_test_base_"))
26+
(d / "file1.txt").write_text("base content")
27+
(d / "subdir").mkdir()
28+
(d / "subdir" / "nested.txt").write_text("nested")
29+
yield d
30+
shutil.rmtree(d, ignore_errors=True)
31+
32+
33+
class TestWorkspaceMount:
34+
def test_mount_and_close(self, base_dir):
35+
ws = Workspace.mount(base_dir)
36+
try:
37+
assert ws.path.exists()
38+
assert (ws.path / ".branchfs_ctl").exists()
39+
assert (ws.path / "file1.txt").read_text() == "base content"
40+
assert ws.fstype == "fuse.branchfs"
41+
finally:
42+
ws.close()
43+
44+
def test_context_manager(self, base_dir):
45+
with Workspace.mount(base_dir) as ws:
46+
assert (ws.path / "file1.txt").read_text() == "base content"
47+
# After exit, mount should be cleaned up
48+
assert not (ws.path / ".branchfs_ctl").exists()
49+
50+
def test_branch_commit(self, base_dir):
51+
with Workspace.mount(base_dir) as ws:
52+
with ws.branch("test_branch") as b:
53+
(b.path / "new_file.txt").write_text("from branch")
54+
# auto-committed
55+
assert (base_dir / "new_file.txt").read_text() == "from branch"
56+
57+
def test_branch_abort(self, base_dir):
58+
with Workspace.mount(base_dir) as ws:
59+
try:
60+
with ws.branch("fail_branch") as b:
61+
(b.path / "bad_file.txt").write_text("should vanish")
62+
raise ValueError("simulated failure")
63+
except ValueError:
64+
pass
65+
# auto-aborted — file should not be in base
66+
assert not (base_dir / "bad_file.txt").exists()
67+
68+
def test_explicit_mountpoint_and_storage(self, base_dir, tmp_path):
69+
mnt = tmp_path / "mnt"
70+
stor = tmp_path / "stor"
71+
with Workspace.mount(base_dir, mountpoint=mnt, storage=stor) as ws:
72+
assert ws.path == mnt.resolve()
73+
assert (ws.path / "file1.txt").exists()
74+
75+
def test_mount_nonexistent_base_raises(self):
76+
with pytest.raises(MountError, match="does not exist"):
77+
Workspace.mount("/nonexistent/path/abc123")
78+
79+
def test_mount_bad_binary_raises(self, base_dir):
80+
with pytest.raises(MountError, match="not found"):
81+
Workspace.mount(base_dir, branchfs_bin="/no/such/binary")

0 commit comments

Comments
 (0)