Skip to main content

memstead_base/
workspace_root.rs

1//! Workspace-root utilities. Today's only consumer is the `memstead_health`
2//! `OUTER_REPO_NOT_IGNORING_MEM_REPO` warning surfaced by the MCP layer:
3//! when the workspace is embedded inside another git repository, the
4//! mem-repo-git directory must be excluded by the outer repo's
5//! `.gitignore` to avoid the gitlink trap.
6//!
7//! Pure path-walking — no IO beyond `metadata` / `read_to_string` for
8//! the gitignore probe — so this module is safe to call from any
9//! engine surface, including read-only health queries.
10
11use std::path::{Path, PathBuf};
12
13/// Walk parent directories from `workspace_root.parent()` upward looking
14/// for a `.git` directory or file. Returns the first ancestor that
15/// carries one, or `None` when none exist (for example, the workspace
16/// is not embedded inside another git repository).
17///
18/// `workspace_root` itself is intentionally skipped: the embedded
19/// `mem-repo/.git/` lives *inside* the workspace and is not the
20/// "outer" repo this helper looks for. We start the walk one level
21/// up so the workspace's own gitdir can never shadow a real outer
22/// repo.
23///
24/// `.git` may be a directory (the common case) or a file (for git
25/// worktrees and submodules). Both shapes count as "this ancestor is
26/// a git checkout."
27pub 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
40/// Returns `true` when the outer repo at `outer_repo_root` contains a
41/// `.gitignore` line that ignores `mem-repo/` (with or without a
42/// leading workspace-relative prefix). The match is whitespace- and
43/// trailing-slash-insensitive: `mem-repo`, `mem-repo/`, `memstead/mem-repo`,
44/// and `memstead/mem-repo/` all count as "ignored." A negation
45/// (`!mem-repo/`) cancels the match.
46///
47/// This is a *best-effort heuristic* against a hand-edited file, not a
48/// full `.gitignore` parser. False negatives are acceptable (the
49/// warning surfaces; the user inspects); false positives would silence
50/// a real misconfiguration, which is why the matcher errs on the side
51/// of literal substring matches and skips comment lines.
52pub 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    // Workspace-relative prefix the outer-repo author would use to
59    // address the mem-repo directory: "<rel>/mem-repo" where <rel>
60    // is the workspace's path relative to the outer repo root.
61    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        // The walker climbs all the way to filesystem root, so a stray
110        // `.git` anywhere in `/var/folders/.../T/` (left behind by an
111        // unrelated test run on macOS) would shadow the assertion. We
112        // confirm the helper returns `None` by checking the *negative
113        // image*: when the walker DOES find a `.git`, that ancestor
114        // must be outside the test's TempDir — proving the test
115        // workspace itself contains no enclosing repo within its own
116        // bounds. Belt-and-braces: also confirm the walker stops at
117        // the root.
118        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        // Workspace's own `.git/` (the mem-repo-git embedded gitdir
152        // sits at `workspace/mem-repo/.git`, but a stray `.git` at the
153        // workspace root itself would be the legacy disk gitdir). The
154        // walker starts at parent, so neither shadows a real outer
155        // repo. As in `returns_none_when_no_outer_git`, we tolerate
156        // an enclosing `.git` outside the TempDir (test-environment
157        // leakage on macOS) by asserting only that no match resolves
158        // to the test workspace itself.
159        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}