agentmux_srv/backend/
claude_layout.rs1const PROJECT_DIR_NAME_MAX: usize = 200;
16
17pub fn project_dir_name(cwd: &str) -> String {
36 let name: String = cwd
37 .encode_utf16()
38 .map(|unit| match u8::try_from(unit) {
39 Ok(b) if b.is_ascii_alphanumeric() => b as char,
40 _ => '-',
41 })
42 .collect();
43 if name.len() <= PROJECT_DIR_NAME_MAX {
44 return name;
45 }
46 format!(
49 "{}-{}",
50 &name[..PROJECT_DIR_NAME_MAX],
51 radix_36(path_hash(cwd))
52 )
53}
54
55fn path_hash(s: &str) -> u32 {
60 let mut hash: i32 = 0;
61 for unit in s.encode_utf16() {
62 hash = hash
63 .wrapping_shl(5)
64 .wrapping_sub(hash)
65 .wrapping_add(i32::from(unit));
66 }
67 hash.unsigned_abs()
68}
69
70fn radix_36(mut n: u32) -> String {
72 const DIGITS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz";
73 if n == 0 {
74 return "0".to_string();
75 }
76 let mut buf = Vec::new();
77 while n > 0 {
78 buf.push(DIGITS[(n % 36) as usize]);
79 n /= 36;
80 }
81 buf.reverse();
82 String::from_utf8(buf).expect("base-36 digits are ASCII")
83}
84
85pub fn memory_project_root(cwd: &std::path::Path) -> std::path::PathBuf {
102 let cwd = physical_dir(cwd);
103 match git_root(&cwd) {
104 Some(root) => canonical_worktree_root(&root),
105 None => cwd,
106 }
107}
108
109pub fn physical_dir(cwd: &std::path::Path) -> std::path::PathBuf {
113 if cfg!(windows) {
114 cwd.to_path_buf()
115 } else {
116 cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf())
117 }
118}
119
120pub fn has_linked_worktrees(root: &std::path::Path) -> bool {
123 std::fs::read_dir(root.join(".git").join("worktrees")).is_ok_and(|mut d| d.next().is_some())
124}
125
126fn git_root(dir: &std::path::Path) -> Option<std::path::PathBuf> {
129 dir.ancestors()
130 .find(|d| std::fs::metadata(d.join(".git")).is_ok_and(|m| m.is_dir() || m.is_file()))
131 .map(std::path::Path::to_path_buf)
132}
133
134fn canonical_worktree_root(root: &std::path::Path) -> std::path::PathBuf {
139 use std::path::{Path, PathBuf};
140 fn read_trimmed(p: &Path) -> Option<String> {
141 std::fs::read_to_string(p).ok().map(|s| s.trim().to_string())
142 }
143 let resolve = || -> Option<PathBuf> {
144 let git_file = read_trimmed(&root.join(".git"))?;
145 let git_dir = real_path(&root.join(git_file.strip_prefix("gitdir:")?.trim()));
146 let common = real_path(&git_dir.join(read_trimmed(&git_dir.join("commondir"))?));
147 if git_dir.parent()? != common.join("worktrees") {
148 return None;
149 }
150 let back = real_path(&git_dir.join(read_trimmed(&git_dir.join("gitdir"))?));
151 if back != real_path(root).join(".git") {
152 return None;
153 }
154 if common.file_name()? != ".git" {
155 return (!common.join(".git").exists()).then_some(common);
158 }
159 common.parent().map(Path::to_path_buf)
160 };
161 resolve().unwrap_or_else(|| root.to_path_buf())
162}
163
164pub fn real_path(p: &std::path::Path) -> std::path::PathBuf {
170 without_verbatim_prefix(p.canonicalize().unwrap_or_else(|_| p.to_path_buf()))
171}
172
173fn without_verbatim_prefix(p: std::path::PathBuf) -> std::path::PathBuf {
177 match p.to_str().and_then(|s| s.strip_prefix(r"\\?\")) {
178 Some(rest) if !rest.starts_with("UNC\\") => std::path::PathBuf::from(rest),
179 _ => p,
180 }
181}
182
183#[cfg(test)]
184pub(crate) mod tests {
185 use super::*;
186
187 #[test]
191 fn a_windows_agent_directory() {
192 assert_eq!(
193 project_dir_name(r"C:\Users\user\.agentmux\agents\agent3-0630k"),
194 "C--Users-user--agentmux-agents-agent3-0630k"
195 );
196 }
197
198 #[test]
199 fn a_posix_path_keeps_the_dashes_inside_a_segment() {
200 assert_eq!(
201 project_dir_name("/home/u/.agentmux/agents/foo-bar"),
202 "-home-u--agentmux-agents-foo-bar"
203 );
204 }
205
206 #[test]
207 fn underscores_and_spaces_become_dashes() {
208 assert_eq!(
209 project_dir_name("/home/u/my_project dir"),
210 "-home-u-my-project-dir"
211 );
212 }
213
214 #[test]
215 fn non_ascii_letters_and_punctuation_become_dashes() {
216 assert_eq!(
217 project_dir_name("C:\\code\\caf\u{e9} (old)@2~x"),
218 "C--code-caf---old--2-x"
219 );
220 }
221
222 #[test]
223 fn a_character_outside_the_bmp_becomes_two_dashes() {
224 assert_eq!(project_dir_name("/x/\u{1F600}y"), "-x---y");
225 }
226
227 #[test]
228 fn a_long_path_is_cut_at_200_and_hashed() {
229 let cwd = format!("/home/u/{}repo", "deeply_nested/".repeat(20));
230 let got = project_dir_name(&cwd);
231 assert_eq!(
232 got,
233 "-home-u-deeply-nested-deeply-nested-deeply-nested-deeply-nested-deeply-nested-\
234 deeply-nested-deeply-nested-deeply-nested-deeply-nested-deeply-nested-deeply-nested-\
235 deeply-nested-deeply-nested-deeply-nes-fygn57"
236 );
237 assert_eq!(got.len(), 207);
238 }
239
240 #[test]
241 fn a_long_windows_path_is_cut_at_200_and_hashed() {
242 let cwd = format!(r"C:\Users\user\{}", "a".repeat(230));
243 assert_eq!(
244 project_dir_name(&cwd),
245 format!("C--Users-user-{}-eqzdy0", "a".repeat(186))
246 );
247 }
248
249 #[test]
250 fn exactly_200_characters_is_not_hashed() {
251 let cwd = "a".repeat(200);
252 assert_eq!(project_dir_name(&cwd), cwd);
253 }
254
255 #[test]
256 fn the_hash_magnitude_of_i32_min_does_not_wrap() {
257 assert_eq!(radix_36(i32::MIN.unsigned_abs()), "zik0zk");
260 assert_eq!(radix_36(0), "0");
261 }
262
263 #[test]
266 fn a_verbatim_drive_path_loses_its_prefix() {
267 use std::path::PathBuf;
268 assert_eq!(without_verbatim_prefix(PathBuf::from(r"\\?\C:\repo")), PathBuf::from(r"C:\repo"));
269 }
270
271 #[test]
272 fn a_verbatim_unc_path_and_a_plain_path_are_kept() {
273 use std::path::PathBuf;
274 for p in [r"\\?\UNC\server\share\repo", r"C:\repo", "/home/u/repo"] {
275 assert_eq!(without_verbatim_prefix(PathBuf::from(p)), PathBuf::from(p));
276 }
277 }
278
279 pub(crate) fn repo_with_worktree(base: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) {
282 let (repo, wt) = (base.join("repo"), base.join("wt"));
283 std::fs::create_dir_all(repo.join("sub")).unwrap();
284 let git = |args: &[&str]| {
285 let out = std::process::Command::new("git")
286 .args(["-c", "user.email=a@b", "-c", "user.name=a"])
287 .args(args)
288 .current_dir(&repo)
289 .output()
290 .unwrap();
291 assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
292 };
293 git(&["init", "-q"]);
294 std::fs::write(repo.join("sub").join("x"), "x").unwrap();
295 git(&["add", "."]);
296 git(&["commit", "-qm", "x"]);
297 git(&["worktree", "add", "-q", wt.to_str().unwrap()]);
298 (real_path(&repo), real_path(&wt))
299 }
300
301 #[test]
302 fn memory_is_keyed_by_the_main_checkout_for_subdirectories_and_worktrees() {
303 let base = tempfile::tempdir().unwrap();
304 let (repo, wt) = repo_with_worktree(base.path());
305 assert_eq!(memory_project_root(&repo), repo);
306 assert_eq!(memory_project_root(&repo.join("sub")), repo);
307 assert_eq!(memory_project_root(&wt), repo, "a linked worktree shares the main checkout's memory");
308 assert_eq!(memory_project_root(&wt.join("sub")), repo);
309 }
310
311 #[test]
312 fn outside_a_repository_memory_is_keyed_by_the_physical_directory() {
313 let base = tempfile::tempdir().unwrap();
314 let real = real_path(base.path()).join("plain");
315 std::fs::create_dir_all(&real).unwrap();
316 assert_eq!(memory_project_root(&real), real);
317 #[cfg(unix)]
318 {
319 let link = base.path().join("link");
320 std::os::unix::fs::symlink(&real, &link).unwrap();
321 assert_eq!(memory_project_root(&link), real);
322 }
323 }
324
325 #[test]
326 fn a_git_file_that_is_not_a_linked_worktree_keeps_its_own_root() {
327 let base = tempfile::tempdir().unwrap();
328 let dir = real_path(base.path()).join("odd");
329 std::fs::create_dir_all(&dir).unwrap();
330 std::fs::write(dir.join(".git"), "gitdir: ../nowhere\n").unwrap();
331 assert_eq!(memory_project_root(&dir), dir);
332 }
333}