memstead_base/
workspace_root.rs1use std::path::{Path, PathBuf};
12
13pub fn find_enclosing_git_repo(workspace_root: &Path) -> Option<PathBuf> {
28 let start = workspace_root.parent()?;
29 let mut current = Some(start);
30 while let Some(dir) = current {
31 let candidate = dir.join(".git");
32 if candidate.is_dir() || candidate.is_file() {
33 return Some(dir.to_path_buf());
34 }
35 current = dir.parent();
36 }
37 None
38}
39
40pub fn outer_repo_ignores_mem_repo(outer_repo_root: &Path, workspace_root: &Path) -> bool {
53 let gitignore = outer_repo_root.join(".gitignore");
54 let Ok(contents) = std::fs::read_to_string(&gitignore) else {
55 return false;
56 };
57
58 let rel_prefix: Option<String> = workspace_root
62 .strip_prefix(outer_repo_root)
63 .ok()
64 .map(|p| p.to_string_lossy().replace('\\', "/"));
65
66 let mut matched = false;
67 for raw in contents.lines() {
68 let line = raw.trim();
69 if line.is_empty() || line.starts_with('#') {
70 continue;
71 }
72 let (negated, body) = match line.strip_prefix('!') {
73 Some(rest) => (true, rest),
74 None => (false, line),
75 };
76 let body = body.trim_end_matches('/').trim();
77 if line_matches_mem_repo(body, rel_prefix.as_deref()) {
78 matched = !negated;
79 }
80 }
81 matched
82}
83
84fn line_matches_mem_repo(body: &str, rel_prefix: Option<&str>) -> bool {
85 let body = body.trim_start_matches('/');
86 if body == "mem-repo" {
87 return true;
88 }
89 if let Some(rel) = rel_prefix {
90 let rel = rel.trim_start_matches('/').trim_end_matches('/');
91 if !rel.is_empty() {
92 let combined = format!("{rel}/mem-repo");
93 if body == combined {
94 return true;
95 }
96 }
97 }
98 false
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104 use std::fs;
105 use tempfile::TempDir;
106
107 #[test]
108 fn returns_none_when_no_outer_git() {
109 let tmp = TempDir::new().unwrap();
119 let workspace = tmp.path().join("workspace");
120 fs::create_dir_all(&workspace).unwrap();
121 match find_enclosing_git_repo(&workspace) {
122 None => {}
123 Some(found) => {
124 let canon_found = found.canonicalize().unwrap_or(found);
125 let canon_tmp = tmp
126 .path()
127 .canonicalize()
128 .unwrap_or(tmp.path().to_path_buf());
129 assert!(
130 !canon_found.starts_with(&canon_tmp),
131 "test environment leaked a `.git` under TempDir: {}",
132 canon_found.display()
133 );
134 }
135 }
136 }
137
138 #[test]
139 fn finds_outer_git_dir() {
140 let tmp = TempDir::new().unwrap();
141 let outer = tmp.path().join("outer");
142 let workspace = outer.join("memstead");
143 fs::create_dir_all(workspace.join(".memstead")).unwrap();
144 fs::create_dir_all(outer.join(".git")).unwrap();
145 let found = find_enclosing_git_repo(&workspace).expect("should find outer .git");
146 assert_eq!(found, outer);
147 }
148
149 #[test]
150 fn skips_workspace_self_git_dir() {
151 let tmp = TempDir::new().unwrap();
160 let workspace = tmp.path().join("workspace");
161 fs::create_dir_all(workspace.join(".git")).unwrap();
162 let canon_workspace = workspace.canonicalize().unwrap_or(workspace.clone());
163 match find_enclosing_git_repo(&workspace) {
164 None => {}
165 Some(found) => {
166 let canon_found = found.canonicalize().unwrap_or(found);
167 assert_ne!(
168 canon_found, canon_workspace,
169 "walker must skip the workspace's own `.git/`"
170 );
171 let canon_tmp = tmp
172 .path()
173 .canonicalize()
174 .unwrap_or(tmp.path().to_path_buf());
175 assert!(
176 !canon_found.starts_with(&canon_tmp),
177 "match must come from outside the TempDir"
178 );
179 }
180 }
181 }
182
183 #[test]
184 fn detects_dot_git_file_for_worktrees() {
185 let tmp = TempDir::new().unwrap();
186 let outer = tmp.path().join("outer");
187 let workspace = outer.join("memstead");
188 fs::create_dir_all(&workspace).unwrap();
189 fs::write(outer.join(".git"), "gitdir: /elsewhere\n").unwrap();
190 let found = find_enclosing_git_repo(&workspace).expect("should detect .git file");
191 assert_eq!(found, outer);
192 }
193
194 #[test]
195 fn ignore_check_matches_bare_mem_repo() {
196 let tmp = TempDir::new().unwrap();
197 let outer = tmp.path().join("outer");
198 let workspace = outer.join("memstead");
199 fs::create_dir_all(&workspace).unwrap();
200 fs::write(outer.join(".gitignore"), "mem-repo/\n").unwrap();
201 assert!(outer_repo_ignores_mem_repo(&outer, &workspace));
202 }
203
204 #[test]
205 fn ignore_check_matches_workspace_prefixed() {
206 let tmp = TempDir::new().unwrap();
207 let outer = tmp.path().join("outer");
208 let workspace = outer.join("memstead");
209 fs::create_dir_all(&workspace).unwrap();
210 fs::write(outer.join(".gitignore"), "memstead/mem-repo/\n").unwrap();
211 assert!(outer_repo_ignores_mem_repo(&outer, &workspace));
212 }
213
214 #[test]
215 fn ignore_check_returns_false_when_not_listed() {
216 let tmp = TempDir::new().unwrap();
217 let outer = tmp.path().join("outer");
218 let workspace = outer.join("memstead");
219 fs::create_dir_all(&workspace).unwrap();
220 fs::write(outer.join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
221 assert!(!outer_repo_ignores_mem_repo(&outer, &workspace));
222 }
223
224 #[test]
225 fn ignore_check_returns_false_when_no_gitignore() {
226 let tmp = TempDir::new().unwrap();
227 let outer = tmp.path().join("outer");
228 let workspace = outer.join("memstead");
229 fs::create_dir_all(&workspace).unwrap();
230 assert!(!outer_repo_ignores_mem_repo(&outer, &workspace));
231 }
232
233 #[test]
234 fn ignore_check_honours_negation() {
235 let tmp = TempDir::new().unwrap();
236 let outer = tmp.path().join("outer");
237 let workspace = outer.join("memstead");
238 fs::create_dir_all(&workspace).unwrap();
239 fs::write(outer.join(".gitignore"), "mem-repo/\n!mem-repo/\n").unwrap();
240 assert!(!outer_repo_ignores_mem_repo(&outer, &workspace));
241 }
242
243 #[test]
244 fn ignore_check_skips_comments() {
245 let tmp = TempDir::new().unwrap();
246 let outer = tmp.path().join("outer");
247 let workspace = outer.join("memstead");
248 fs::create_dir_all(&workspace).unwrap();
249 fs::write(outer.join(".gitignore"), "# mem-repo/\n").unwrap();
250 assert!(!outer_repo_ignores_mem_repo(&outer, &workspace));
251 }
252}