1use acp_utils::notifications::{WorkspaceEntry, WorkspaceMoveTarget};
2use std::io;
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use thiserror::Error;
6
7mod git;
8mod registry;
9#[doc(hidden)]
10pub mod testing;
11
12use git::GitError;
13pub(crate) use git::current_ref;
14use registry::{RegistryError, WorkspaceRegistry};
15
16pub struct WorkspaceManager {
19 registry: WorkspaceRegistry,
20 cloner: Arc<dyn DirectoryCloner>,
21}
22
23pub trait DirectoryCloner: Send + Sync {
25 fn clone_dir(&self, src: &Path, dst: &Path) -> Result<(), CloneError>;
26}
27
28pub struct PlatformDirectoryCloner;
30
31#[derive(Debug, Error)]
33pub enum WorkspaceError {
34 #[error("{} is not a git repository: {source}", .path.display())]
35 NotARepository { path: PathBuf, source: GitError },
36 #[error("failed to resolve repository identity: {0}")]
37 RepositoryIdentity(GitError),
38 #[error("target workspace does not exist: {}", .0.display())]
39 TargetMissing(PathBuf),
40 #[error("workspace belongs to a different repository: {}", .0.display())]
41 DifferentRepository(PathBuf),
42 #[error("target workspace is on a different commit: {}", .0.display())]
43 DifferentHead(PathBuf),
44 #[error("session is already in that workspace")]
45 AlreadyInWorkspace,
46 #[error("workspace name must not be empty")]
47 EmptyName,
48 #[error("workspace name must not contain path separators: {0}")]
49 NameSeparator(String),
50 #[error("workspace name is reserved: {0}")]
51 ReservedName(String),
52 #[error("source repository root has no parent directory: {}", .0.display())]
53 NoParent(PathBuf),
54 #[error("target path already exists: {}", .0.display())]
55 TargetPathExists(PathBuf),
56 #[error("target workspace has uncommitted changes: {}", .0.display())]
57 TargetDirty(PathBuf),
58 #[error(transparent)]
59 Registry(#[from] RegistryError),
60 #[error(transparent)]
61 Clone(#[from] CloneError),
62 #[error(transparent)]
63 Git(#[from] GitError),
64 #[error("filesystem error: {0}")]
65 Io(#[from] io::Error),
66}
67
68#[derive(Debug, Error)]
70pub enum CloneError {
71 #[error("path contains an interior NUL byte: {}", .0.display())]
72 InvalidPath(PathBuf),
73 #[error("failed to clone {} to {}: {source}", .src.display(), .dst.display())]
74 Clone { src: PathBuf, dst: PathBuf, source: io::Error },
75}
76
77impl WorkspaceManager {
78 pub fn new() -> io::Result<Self> {
79 Ok(Self::from_registry_and_cloner(WorkspaceRegistry::new()?, Arc::new(PlatformDirectoryCloner)))
80 }
81
82 pub fn from_registry_path(path: PathBuf) -> Self {
83 Self::from_registry_and_cloner(WorkspaceRegistry::from_path(path), Arc::new(PlatformDirectoryCloner))
84 }
85
86 #[doc(hidden)]
87 pub fn from_registry_path_with_cloner(path: PathBuf, cloner: Arc<dyn DirectoryCloner>) -> Self {
88 Self::from_registry_and_cloner(WorkspaceRegistry::from_path(path), cloner)
89 }
90
91 fn from_registry_and_cloner(registry: WorkspaceRegistry, cloner: Arc<dyn DirectoryCloner>) -> Self {
92 Self { registry, cloner }
93 }
94
95 pub fn list(&self, cwd: &Path) -> Result<Vec<WorkspaceEntry>, WorkspaceError> {
99 let (src_root, repo_key) = resolve_repo(cwd)?;
100 self.registry.register(&repo_key, &src_root)?;
101 Ok(self
102 .registry
103 .workspaces_for(&repo_key)?
104 .into_iter()
105 .map(|record| WorkspaceEntry { is_current: record.path == src_root, path: record.path })
106 .collect())
107 }
108
109 pub fn move_to(&self, cwd: &Path, target: &WorkspaceMoveTarget) -> Result<PathBuf, WorkspaceError> {
113 let (src_root, repo_key) = resolve_repo(cwd)?;
114
115 let dst_root = match target {
116 WorkspaceMoveTarget::Existing { path } => {
117 if !path.exists() {
118 return Err(WorkspaceError::TargetMissing(path.clone()));
119 }
120 let (dst_root, dst_key) = resolve_repo(path)?;
121 if dst_key != repo_key {
122 return Err(WorkspaceError::DifferentRepository(path.clone()));
123 }
124 if dst_root == src_root {
125 return Err(WorkspaceError::AlreadyInWorkspace);
126 }
127 if git::head_commit_hash(&src_root)? != git::head_commit_hash(&dst_root)? {
128 return Err(WorkspaceError::DifferentHead(path.clone()));
129 }
130 dst_root
131 }
132 WorkspaceMoveTarget::New { name } => sibling_workspace_path(&src_root, name)?,
133 };
134
135 move_changes(&src_root, &dst_root, self.cloner.as_ref())?;
136 self.registry.register(&repo_key, &src_root)?;
137 self.registry.register(&repo_key, &dst_root)?;
138 let canonical_cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
139 let relocated = canonical_cwd
140 .strip_prefix(&src_root)
141 .ok()
142 .filter(|relative| !relative.as_os_str().is_empty())
143 .map(|relative| dst_root.join(relative));
144 Ok(relocated.filter(|candidate| candidate.exists()).unwrap_or(dst_root))
145 }
146}
147
148impl WorkspaceError {
149 pub fn is_invalid_input(&self) -> bool {
150 match self {
151 Self::NotARepository { .. }
152 | Self::RepositoryIdentity(_)
153 | Self::TargetMissing(_)
154 | Self::DifferentRepository(_)
155 | Self::DifferentHead(_)
156 | Self::AlreadyInWorkspace
157 | Self::EmptyName
158 | Self::NameSeparator(_)
159 | Self::ReservedName(_)
160 | Self::NoParent(_)
161 | Self::TargetPathExists(_)
162 | Self::TargetDirty(_) => true,
163 Self::Registry(_) | Self::Clone(_) | Self::Git(_) | Self::Io(_) => false,
164 }
165 }
166}
167
168fn resolve_repo(cwd: &Path) -> Result<(PathBuf, String), WorkspaceError> {
169 let root =
170 git::repo_root(cwd).map_err(|source| WorkspaceError::NotARepository { path: cwd.to_path_buf(), source })?;
171 let root = root.canonicalize().unwrap_or(root);
172 let key = git::root_commit_hash(&root).map_err(WorkspaceError::RepositoryIdentity)?;
173 Ok((root, key))
174}
175
176fn sibling_workspace_path(src_root: &Path, name: &str) -> Result<PathBuf, WorkspaceError> {
177 if name.is_empty() {
178 return Err(WorkspaceError::EmptyName);
179 }
180 if name.chars().any(std::path::is_separator) {
181 return Err(WorkspaceError::NameSeparator(name.to_string()));
182 }
183 if name == "." || name == ".." {
184 return Err(WorkspaceError::ReservedName(name.to_string()));
185 }
186
187 let parent = src_root.parent().ok_or_else(|| WorkspaceError::NoParent(src_root.to_path_buf()))?;
188 let dst = parent.join(name);
189 if dst.exists() {
190 return Err(WorkspaceError::TargetPathExists(dst));
191 }
192 Ok(dst)
193}
194
195fn move_changes(src: &Path, dst: &Path, cloner: &dyn DirectoryCloner) -> Result<(), WorkspaceError> {
196 if dst.exists() {
197 if !git::is_clean(dst)? {
198 return Err(WorkspaceError::TargetDirty(dst.to_path_buf()));
199 }
200
201 let untracked = git::untracked_files(src)?;
202 for rel in &untracked {
203 let dst_file = dst.join(rel);
204 if dst_file.exists() {
205 return Err(WorkspaceError::TargetPathExists(dst_file));
206 }
207 }
208
209 let patch = git::diff_head(src)?;
210 git::apply_patch(dst, &patch)?;
211 git::copy_untracked_files(src, dst, &untracked)?;
212 } else {
213 cloner.clone_dir(src, dst)?;
214 }
215
216 git::reset_clean(src)?;
217 Ok(())
218}
219
220impl DirectoryCloner for PlatformDirectoryCloner {
221 fn clone_dir(&self, src: &Path, dst: &Path) -> Result<(), CloneError> {
222 platform_clone_dir(src, dst)
223 }
224}
225
226#[cfg(target_os = "macos")]
227fn platform_clone_dir(src: &Path, dst: &Path) -> Result<(), CloneError> {
228 use std::ffi::CString;
229 use std::os::unix::ffi::OsStrExt;
230
231 let src_c = CString::new(src.as_os_str().as_bytes()).map_err(|_| CloneError::InvalidPath(src.to_path_buf()))?;
232
233 let dst_c = CString::new(dst.as_os_str().as_bytes()).map_err(|_| CloneError::InvalidPath(dst.to_path_buf()))?;
234
235 let ret = unsafe { libc::clonefile(src_c.as_ptr(), dst_c.as_ptr(), 0) };
236 if ret != 0 {
237 return Err(CloneError::Clone {
238 src: src.to_path_buf(),
239 dst: dst.to_path_buf(),
240 source: io::Error::last_os_error(),
241 });
242 }
243
244 Ok(())
245}
246
247#[cfg(not(target_os = "macos"))]
248fn platform_clone_dir(src: &Path, dst: &Path) -> Result<(), CloneError> {
249 use std::process::Command;
250
251 let output = Command::new("cp")
252 .arg("-a")
253 .arg("--reflink=auto")
254 .arg(src)
255 .arg(dst)
256 .output()
257 .map_err(|e| CloneError::Clone { src: src.to_path_buf(), dst: dst.to_path_buf(), source: e })?;
258
259 if !output.status.success() {
260 let stderr = String::from_utf8_lossy(&output.stderr);
261 let detail = stderr.trim();
262 let message = if detail.is_empty() {
263 "failed to clone workspace directory".to_string()
264 } else {
265 format!("failed to clone workspace directory: {detail}")
266 };
267 return Err(CloneError::Clone {
268 src: src.to_path_buf(),
269 dst: dst.to_path_buf(),
270 source: io::Error::other(message),
271 });
272 }
273 Ok(())
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279 use crate::workspace::testing::{StdCopyCloner, init_repo};
280 use std::fs;
281 use tempfile::TempDir;
282
283 #[test]
284 fn move_to_rejects_invalid_new_names() {
285 let tmp = TempDir::new().unwrap();
286 let repo = init_repo(tmp.path(), "repo");
287 fs::create_dir_all(tmp.path().join("taken")).unwrap();
288 let manager = WorkspaceManager::from_registry_path_with_cloner(
289 tmp.path().join("workspaces.json"),
290 Arc::new(StdCopyCloner),
291 );
292
293 let move_to = |name: &str| manager.move_to(&repo, &WorkspaceMoveTarget::New { name: name.to_string() });
294
295 assert!(matches!(move_to(""), Err(WorkspaceError::EmptyName)));
296 assert!(matches!(move_to("a/b"), Err(WorkspaceError::NameSeparator(_))));
297 assert!(matches!(move_to("."), Err(WorkspaceError::ReservedName(_))));
298 assert!(matches!(move_to(".."), Err(WorkspaceError::ReservedName(_))));
299 assert!(matches!(move_to("taken"), Err(WorkspaceError::TargetPathExists(_))));
300 }
301
302 #[test]
303 fn list_registers_repo_root_and_marks_it_current() {
304 let tmp = TempDir::new().unwrap();
305 let repo = init_repo(tmp.path(), "repo");
306 let manager = WorkspaceManager::from_registry_path_with_cloner(
307 tmp.path().join("workspaces.json"),
308 Arc::new(StdCopyCloner),
309 );
310
311 let workspaces = manager.list(&repo).unwrap();
312
313 assert_eq!(workspaces.len(), 1);
314 assert!(workspaces[0].is_current);
315 assert_eq!(workspaces[0].path, repo.canonicalize().unwrap());
316 }
317}