claude/sigit-cloud-agent-expansion-reox0a
claude/github-issue-40-0au5ua
claude/linked-issue-acp-tui-u4vvpa
claude/minor-release-cut-vkugow
claude/sigit-acp-local-chat-cx6380
claude/sigit-cloud-agent-expansion-reox0a
claude/tool-permission-system
claude/zen-feynman-0u78dk
development
feature/acp-cancel-request
feature/acp-session-list-cursor
feature/acp-v1-conformance
feature/agent-tools-multiedit-glob-todos-remember
feature/cargo-workspace
feature/hooks
feature/load-local-model-explicitly
feature/sigit-agent-profile
feature/sigit-code-cloud
feature/xcode-mcp-server
fix/acp-replay-loaded-session-history
fix/acp-replay-reasoning-and-attachments
fix/dsml-doubled-bar-markers
main
release/v1.5.10
release/v1.5.6
release/v1.6.1
| 1 | //! The directories the current session treats as project roots. |
| 2 | //! |
| 3 | //! An editor can open more than one directory in a single project — Zed calls |
| 4 | //! it a multi-root project — and ACP carries the extra ones as |
| 5 | //! `additional_directories` on every session request. The process still has a |
| 6 | //! single working directory (the primary `cwd` it `set_current_dir`s to), so |
| 7 | //! the extra roots live here, in a process-global that project-local discovery |
| 8 | //! reads alongside `std::env::current_dir()`: skills (`skills.rs`), slash |
| 9 | //! commands (`commands.rs`), subagent types (`subagents.rs`) and instruction |
| 10 | //! files (`instructions.rs`). |
| 11 | //! |
| 12 | //! MCP servers are deliberately not on that list. Discovery runs once at |
| 13 | //! startup (`mcp::init`), before any session exists, so a second root's |
| 14 | //! `.sigit/mcp.toml` has nobody to tell. |
| 15 | |
| 16 | use std::path::{Component, Path, PathBuf}; |
| 17 | use std::sync::RwLock; |
| 18 | |
| 19 | /// The session's roots beyond `cwd`. Empty for an ordinary single-root project. |
| 20 | static ADDITIONAL_ROOTS: RwLock<Vec<PathBuf>> = RwLock::new(Vec::new()); |
| 21 | |
| 22 | /// Record the session's extra roots, returning the ones that were kept. |
| 23 | /// |
| 24 | /// Entries that aren't directories, repeat, or just name `cwd` again are |
| 25 | /// dropped — an editor is free to send any of those, and every consumer here |
| 26 | /// would either scan nothing or do the same work twice. |
| 27 | pub fn set_additional_roots(cwd: &Path, roots: &[PathBuf]) -> Vec<PathBuf> { |
| 28 | let mut seen: Vec<PathBuf> = vec![canonical_key(cwd)]; |
| 29 | let mut kept: Vec<PathBuf> = Vec::new(); |
| 30 | |
| 31 | for root in roots { |
| 32 | if !root.is_dir() { |
| 33 | log::warn!( |
| 34 | "ignoring workspace root {} (not a directory)", |
| 35 | root.display() |
| 36 | ); |
| 37 | continue; |
| 38 | } |
| 39 | let key = canonical_key(root); |
| 40 | if seen.contains(&key) { |
| 41 | continue; |
| 42 | } |
| 43 | seen.push(key); |
| 44 | kept.push(root.clone()); |
| 45 | } |
| 46 | |
| 47 | if let Ok(mut guard) = ADDITIONAL_ROOTS.write() { |
| 48 | *guard = kept.clone(); |
| 49 | } |
| 50 | kept |
| 51 | } |
| 52 | |
| 53 | /// The extra roots recorded for the session. |
| 54 | pub fn additional_roots() -> Vec<PathBuf> { |
| 55 | ADDITIONAL_ROOTS |
| 56 | .read() |
| 57 | .map(|guard| guard.clone()) |
| 58 | .unwrap_or_default() |
| 59 | } |
| 60 | |
| 61 | /// Every directory the session treats as a project root: the process working |
| 62 | /// directory first, then the extra roots. Project-local discovery scans these |
| 63 | /// in order, so the primary root keeps winning name collisions. |
| 64 | pub fn project_dirs() -> Vec<PathBuf> { |
| 65 | let mut dirs = Vec::new(); |
| 66 | if let Ok(cwd) = std::env::current_dir() { |
| 67 | dirs.push(cwd); |
| 68 | } |
| 69 | dirs.extend(additional_roots()); |
| 70 | dirs |
| 71 | } |
| 72 | |
| 73 | /// A comparison key for a path: its canonical form when the filesystem can |
| 74 | /// give one, and otherwise the path made absolute and lexically normalized. |
| 75 | /// |
| 76 | /// The fallback matters. `canonicalize` fails on a path the process cannot |
| 77 | /// resolve (an unreadable parent, a root that vanished between the client |
| 78 | /// sending it and us reading it), and returning the path untouched there would |
| 79 | /// let `.` and `..` segments or a relative spelling of a root that is already |
| 80 | /// in the list read as a new one. |
| 81 | pub fn canonical_key(path: &Path) -> PathBuf { |
| 82 | path.canonicalize().unwrap_or_else(|_| normalize(path)) |
| 83 | } |
| 84 | |
| 85 | /// Absolute + lexically normalized: `.` dropped, `..` folded into the previous |
| 86 | /// segment. Purely textual, so it never touches the filesystem. |
| 87 | fn normalize(path: &Path) -> PathBuf { |
| 88 | let absolute = if path.is_absolute() { |
| 89 | path.to_path_buf() |
| 90 | } else { |
| 91 | std::env::current_dir() |
| 92 | .map(|cwd| cwd.join(path)) |
| 93 | .unwrap_or_else(|_| path.to_path_buf()) |
| 94 | }; |
| 95 | |
| 96 | let mut out = PathBuf::new(); |
| 97 | for component in absolute.components() { |
| 98 | match component { |
| 99 | Component::CurDir => {} |
| 100 | Component::ParentDir => { |
| 101 | if !out.pop() { |
| 102 | out.push(component); |
| 103 | } |
| 104 | } |
| 105 | other => out.push(other), |
| 106 | } |
| 107 | } |
| 108 | out |
| 109 | } |
| 110 | |
| 111 | #[cfg(test)] |
| 112 | mod tests { |
| 113 | use super::*; |
| 114 | |
| 115 | /// One process-global under test, so the cases share a lock and a cleanup. |
| 116 | fn with_roots<T>(cwd: &Path, roots: &[PathBuf], test: impl FnOnce(Vec<PathBuf>) -> T) -> T { |
| 117 | static GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(()); |
| 118 | let _lock = GUARD.lock().unwrap_or_else(|poison| poison.into_inner()); |
| 119 | let kept = set_additional_roots(cwd, roots); |
| 120 | let out = test(kept); |
| 121 | set_additional_roots(cwd, &[]); |
| 122 | out |
| 123 | } |
| 124 | |
| 125 | #[test] |
| 126 | fn keeps_real_directories_and_drops_the_rest() { |
| 127 | let temp = std::env::temp_dir().join(format!("sigit-ws-{}", uuid::Uuid::new_v4())); |
| 128 | let cwd = temp.join("primary"); |
| 129 | let extra = temp.join("extra"); |
| 130 | std::fs::create_dir_all(&cwd).unwrap(); |
| 131 | std::fs::create_dir_all(&extra).unwrap(); |
| 132 | let missing = temp.join("gone"); |
| 133 | |
| 134 | with_roots( |
| 135 | &cwd, |
| 136 | &[extra.clone(), extra.clone(), cwd.clone(), missing], |
| 137 | |kept| { |
| 138 | assert_eq!(kept, vec![extra.clone()]); |
| 139 | assert_eq!(additional_roots(), vec![extra.clone()]); |
| 140 | }, |
| 141 | ); |
| 142 | |
| 143 | assert!(additional_roots().is_empty()); |
| 144 | std::fs::remove_dir_all(&temp).ok(); |
| 145 | } |
| 146 | |
| 147 | #[test] |
| 148 | fn keys_a_path_the_filesystem_cannot_resolve() { |
| 149 | // Nothing here exists, so `canonicalize` fails on every one of them and |
| 150 | // the lexical fallback is what has to make the three agree. |
| 151 | let base = std::env::temp_dir().join("sigit-ws-missing"); |
| 152 | let direct = canonical_key(&base.join("root")); |
| 153 | let dotted = canonical_key(&base.join(".").join("root")); |
| 154 | let backtracked = canonical_key(&base.join("other").join("..").join("root")); |
| 155 | |
| 156 | assert_eq!(direct, dotted); |
| 157 | assert_eq!(direct, backtracked); |
| 158 | assert!(direct.is_absolute()); |
| 159 | } |
| 160 | } |