Skip to main content

aether_cli/workspace/
mod.rs

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
15/// Manages the workspaces of a repository: listing the known clones and
16/// moving uncommitted changes between them.
17pub struct WorkspaceManager {
18    registry: WorkspaceRegistry,
19    cloner: Arc<dyn DirectoryCloner>,
20}
21
22/// Clones a directory into a new destination path.
23pub trait DirectoryCloner: Send + Sync {
24    fn clone_dir(&self, src: &Path, dst: &Path) -> Result<(), CloneError>;
25}
26
27/// Production directory cloner selected for the current platform.
28pub struct PlatformDirectoryCloner;
29
30/// Errors returned by [`WorkspaceManager`] operations.
31#[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/// Errors returned when cloning a workspace directory.
68#[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    /// Lists every managed workspace sharing `cwd`'s repository, registering
95    /// `cwd`'s own repo root as a side effect so it becomes a known move
96    /// target for other workspaces.
97    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    /// Moves the uncommitted changes from `cwd`'s workspace to `target`
109    /// (cloning a new sibling directory when the target is new) and
110    /// returns the directory within the target corresponding to `cwd`.
111    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}