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