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