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
410from pathlib import Path
11+ from typing import Optional
512
613from .base import FSBackend
714from .branch import Branch , OnSuccessAction , OnErrorAction
815from .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
1189class 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+ )
0 commit comments