sandbox_core/sandbox.rs
1use std::path::{Path, PathBuf};
2
3use async_trait::async_trait;
4use rap_protocol::ThreadId;
5
6use crate::error::SandboxError;
7use crate::types::{ChangedFile, RepoState, SandboxMode};
8
9/// A spawned command with its child process handle.
10pub struct SpawnedCommand {
11 pub child: tokio::process::Child,
12}
13
14/// Context passed to backends and [`ModeProvider`]s during `clone_repo` so
15/// they can set up the repository and build a [`RepoState`].
16pub struct CloneContext<'a> {
17 /// The group_id (thread id) initializing the repo.
18 pub group_id: &'a ThreadId<str>,
19 /// The path (or remote URI) as originally requested by the caller,
20 /// before any backend resolution. Providers that use their own root
21 /// markers should walk up from here (and return the root they find via
22 /// [`ModeInit::repo_root`]).
23 pub requested_path: &'a str,
24 /// The bookmark name for this sandbox (`sandbox-<group_id>`).
25 pub bookmark: &'a str,
26 /// Base bookmark to resolve against (e.g. `sandbox-<id>`) when the caller
27 /// passed a `base_thread_id`; `None` means base off the current tip.
28 pub base_bookmark: Option<&'a str>,
29}
30
31/// Result of a [`ModeProvider`] claiming a repo during `clone_repo`.
32pub struct ModeInit {
33 /// The mode to store in the repo's [`RepoState`].
34 pub mode: SandboxMode,
35 /// The repository root as resolved by the provider (canonicalized).
36 /// The server stores this as the repo's `remote_uri` instead of the
37 /// backend's jj/git-based resolution, and notes the detected root in the
38 /// response if the requested path was nested inside it.
39 pub repo_root: PathBuf,
40 /// Human-facing message returned from `clone_repo`.
41 pub message: String,
42 /// If `true`, the server eagerly calls `create_sandbox` right after
43 /// `clone_repo` (e.g. to front-load expensive setup).
44 pub precreate: bool,
45}
46
47/// Extension point for sandbox modes.
48///
49/// Providers are registered on a backend (see the local backend's
50/// `with_provider`) and are consulted for all mode-specific behavior. The
51/// built-in Jujutsu and Git modes are themselves implemented as providers
52/// registered by default, so external providers are fully symmetric with
53/// them. The core server treats every non-[`SandboxMode::Direct`] mode as
54/// opaque and delegates here, so mode-specific concepts never leak into the
55/// core crate.
56#[async_trait]
57pub trait ModeProvider: Send + Sync {
58 /// Whether this provider handles the given mode. Used to route all
59 /// per-sandbox operations to the right provider.
60 fn handles(&self, mode: &SandboxMode) -> bool;
61
62 /// Inspect the repo during `clone_repo`; return `Some(..)` to claim it,
63 /// or `None` to let later-registered providers try. Providers are
64 /// consulted in registration order (externally-registered providers
65 /// first, then jj, then git).
66 ///
67 /// `repo_path` is the repository as set up by the backend (the resolved
68 /// local root, or a local mirror of a remote); the originally requested
69 /// path is available as [`CloneContext::requested_path`].
70 async fn detect(
71 &self,
72 repo_path: &Path,
73 ctx: &CloneContext<'_>,
74 ) -> Result<Option<ModeInit>, SandboxError>;
75
76 /// Create (or otherwise materialize) the sandbox working directory for a
77 /// [`RepoState`] in this provider's mode.
78 async fn create_sandbox(&self, state: &RepoState) -> Result<PathBuf, SandboxError>;
79
80 /// Refresh a cached sandbox directory before it is reused (e.g. `jj
81 /// workspace update-stale`).
82 async fn refresh_sandbox(
83 &self,
84 _sandbox_dir: &Path,
85 _state: &RepoState,
86 ) -> Result<(), SandboxError> {
87 Ok(())
88 }
89
90 /// Record a change description on the sandbox before an operation runs
91 /// (e.g. `jj describe`). Providers that apply descriptions at push time
92 /// can keep the default no-op.
93 async fn describe(
94 &self,
95 _sandbox_dir: &Path,
96 _state: &RepoState,
97 _description: &str,
98 ) -> Result<(), SandboxError> {
99 Ok(())
100 }
101
102 /// Detect whether the sandbox's target (e.g. its bookmark) was moved
103 /// externally since the last push. Returns a human-facing warning to
104 /// prepend to the tool result, or `None`.
105 async fn detect_external_change(
106 &self,
107 _sandbox_dir: &Path,
108 _state: &RepoState,
109 ) -> Option<String> {
110 None
111 }
112
113 /// Push the sandbox working copy back to its source.
114 async fn push_sandbox(
115 &self,
116 sandbox_dir: &Path,
117 state: &RepoState,
118 description: Option<&str>,
119 ) -> Result<(), SandboxError>;
120
121 /// Squash the changes from `from_bookmark` into the sandbox, including
122 /// any push needed to make the result visible at the sandbox's bookmark.
123 async fn squash(
124 &self,
125 sandbox_dir: &Path,
126 state: &RepoState,
127 from_bookmark: &str,
128 ) -> Result<(), SandboxError>;
129
130 /// Compute the set of changed files for the diff view.
131 async fn diff_files(
132 &self,
133 sandbox_dir: &Path,
134 state: &RepoState,
135 ) -> Result<Vec<ChangedFile>, SandboxError>;
136
137 /// Permanently clean up the sandbox.
138 async fn cleanup(&self, sandbox_dir: &Path, state: &RepoState) -> Result<(), SandboxError>;
139
140 /// Extra writable paths to grant when spawning commands in this sandbox.
141 fn extra_writable_paths(&self, _sandbox_dir: &Path) -> Vec<PathBuf> {
142 Vec::new()
143 }
144
145 /// Best-effort SYNCHRONOUS cleanup, used from `Drop` where no async runtime
146 /// is available.
147 fn cleanup_blocking(&self, _sandbox_dir: &Path, _state: &RepoState) {}
148}
149
150/// Trait for the sandbox backend.
151/// Handles creating sandboxes (temp dirs managed by the mode's
152/// [`ModeProvider`], e.g. jj workspaces or git worktrees) and managing the
153/// remote (local path vs s3).
154#[async_trait]
155pub trait SandboxBackend: Send + Sync {
156 /// Spin up a sandbox temp dir with a jj clone pointing at the remote,
157 /// checked out to the given working copy commit.
158 async fn create_sandbox(&self, state: &RepoState) -> Result<PathBuf, SandboxError>;
159
160 /// Spawn a command inside the sandbox directory, returning the child process.
161 /// stdout and stderr MUST be piped so the caller can stream output.
162 ///
163 /// `argv` is the raw argument vector: `argv[0]` is the program name and
164 /// `argv[1..]` are its arguments. No shell wrapping is performed.
165 ///
166 /// `extra_writable` grants write access to additional paths beyond the
167 /// sandbox directory (e.g. the original repo for `git push`).
168 ///
169 /// `sandbox_writable` controls whether the sandbox directory itself is
170 /// writable. Set to `false` for Direct mode where no file writes are allowed.
171 async fn spawn_command(
172 &self,
173 sandbox_dir: &Path,
174 argv: &[&str],
175 extra_writable: &[&Path],
176 sandbox_writable: bool,
177 ) -> Result<SpawnedCommand, SandboxError>;
178
179 /// Push the updated working copy from the sandbox back to the remote.
180 /// `description` is an optional commit message; when `None` the backend
181 /// uses a default like `sandbox-{group_id}`.
182 async fn push_sandbox(
183 &self,
184 sandbox_dir: &Path,
185 group_id: &ThreadId<str>,
186 description: Option<&str>,
187 ) -> Result<(), SandboxError>;
188
189 /// Clean up the sandbox temp dir.
190 async fn cleanup_sandbox(&self, sandbox_dir: &Path) -> Result<(), SandboxError>;
191
192 /// Permanently clean up a sandbox associated with the given group_id.
193 ///
194 /// Called when a thread is closed — the sandbox is no longer needed.
195 /// On the local backend this runs `jj workspace forget` and deletes the
196 /// cached directory. On the remote backend this is a no-op since sandboxes
197 /// are ephemeral temp dirs cleaned up after each invocation.
198 async fn cleanup_sandbox_permanently(
199 &self,
200 _group_id: &ThreadId<str>,
201 ) -> Result<(), SandboxError> {
202 Ok(())
203 }
204
205 /// Called on `clone_repo`: the single entry point for repository setup
206 /// and mode detection.
207 ///
208 /// The backend first sets up the repository from
209 /// [`CloneContext::requested_path`] (e.g. the local backend resolves and
210 /// canonicalizes the repo root; the remote backend mirrors the git URL
211 /// onto shared storage), then consults its [`ModeProvider`]s in order and
212 /// returns the first claim. [`ModeInit::repo_root`] becomes the repo's
213 /// `remote_uri`. Returns `Ok(None)` when no provider claims the repo.
214 async fn detect_mode(&self, ctx: &CloneContext<'_>) -> Result<Option<ModeInit>, SandboxError>;
215
216 /// Called on `open_sandbox_direct`: resolve the repository root for
217 /// Direct mode, which operates on the original directory and bypasses
218 /// mode providers. Returns the root to store as the repo's `remote_uri`.
219 async fn init_direct(&self, _repo: &str) -> Result<String, SandboxError> {
220 Err(SandboxError::Other(
221 "Direct mode is not supported by this backend".to_owned(),
222 ))
223 }
224
225 /// Record a change description on the sandbox before an operation runs.
226 async fn describe_sandbox(
227 &self,
228 _sandbox_dir: &Path,
229 _state: &RepoState,
230 _description: &str,
231 ) -> Result<(), SandboxError> {
232 Ok(())
233 }
234
235 /// Detect whether the sandbox's target was moved externally since the
236 /// last push. Returns a human-facing warning, or `None`.
237 async fn detect_external_change(
238 &self,
239 _sandbox_dir: &Path,
240 _state: &RepoState,
241 ) -> Option<String> {
242 None
243 }
244
245 /// Squash `from_bookmark` into the sandbox via the mode's provider.
246 async fn squash_sandbox(
247 &self,
248 _sandbox_dir: &Path,
249 _state: &RepoState,
250 _from_bookmark: &str,
251 ) -> Result<(), SandboxError> {
252 Err(SandboxError::Other(
253 "squash is not supported by this backend".to_owned(),
254 ))
255 }
256
257 /// Compute changed files for the sandbox diff via the mode's provider.
258 async fn diff_files(
259 &self,
260 _sandbox_dir: &Path,
261 _state: &RepoState,
262 ) -> Result<Vec<ChangedFile>, SandboxError> {
263 Ok(Vec::new())
264 }
265}