use std::path::{Path, PathBuf}; use std::process::Command; use miette::Diagnostic; use thiserror::Error; #[derive(Debug, Error, Diagnostic)] pub enum WorkspaceError { #[error("Failed to resolve absolute path for {0}: {1}")] #[diagnostic(code(sandcage::workspace::canonicalize_failed))] CanonicalizeFailed(PathBuf, #[source] std::io::Error), } pub type Result = std::result::Result; pub fn resolve_workspace(path: Option<&Path>) -> Result { // Determine starting path let raw = match path { Some(p) => p.to_path_buf(), None => std::env::current_dir() .map_err(|e| WorkspaceError::CanonicalizeFailed(PathBuf::from("."), e))?, }; // Resolve to absolute, canonical path (follows symlinks, checks existence) let absolute = raw .canonicalize() .map_err(|e| WorkspaceError::CanonicalizeFailed(raw.clone(), e))?; // Ask git for the worktree root; silently fall back if not a git repo let git_root = Command::new("git") .args(["rev-parse", "--show-toplevel"]) .current_dir(&absolute) .output(); match git_root { Ok(output) if output.status.success() => { let root = String::from_utf8_lossy(&output.stdout) .trim() .to_string(); Ok(PathBuf::from(root)) } // Not a git repo, or git not installed — return the absolute path as-is _ => Ok(absolute), } } #[cfg(test)] mod tests { use super::*; use std::process::Command; use tempfile::TempDir; /// Create a temporary directory and initialise it as a git repository. /// Returns the `TempDir` guard (keep it alive for the test) and the path. fn make_git_repo() -> (TempDir, PathBuf) { let dir = TempDir::new().expect("create tempdir"); let path = dir.path().to_path_buf(); // git init let status = Command::new("git") .args(["init"]) .current_dir(&path) .status() .expect("run git init"); assert!(status.success(), "git init failed"); (dir, path) } #[test] fn returns_repo_root_when_inside_git_repo() { let (_guard, repo_root) = make_git_repo(); // Create a nested directory inside the repo let nested = repo_root.join("a").join("b"); std::fs::create_dir_all(&nested).expect("create nested dirs"); // resolve_workspace from the nested dir should return the repo root let resolved = resolve_workspace(Some(&nested)).expect("resolve_workspace"); assert_eq!( resolved.canonicalize().unwrap(), repo_root.canonicalize().unwrap(), "expected repo root, got {resolved:?}" ); } #[test] fn returns_directory_itself_when_not_in_git_repo() { // Create a temp dir that is NOT a git repo (no git init) let dir = TempDir::new().expect("create tempdir"); // Make sure there is no ancestor git repo that would pollute the result. // tempfile creates dirs under the system temp folder, which is typically // outside any user repo, so this should be safe. let path = dir.path().to_path_buf(); let resolved = resolve_workspace(Some(&path)).expect("resolve_workspace"); assert_eq!( resolved.canonicalize().unwrap(), path.canonicalize().unwrap(), "expected the directory itself, got {resolved:?}" ); } #[test] fn none_path_uses_current_directory() { // When None is passed the function should not error; it should return // something (either the cwd or its git root). let result = resolve_workspace(None); assert!(result.is_ok(), "resolve_workspace(None) returned error: {result:?}"); let resolved = result.unwrap(); assert!(resolved.is_absolute(), "result should be absolute: {resolved:?}"); } }