Skip to main content

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}