Skip to main content

memstead_cli/
outer_gitignore.rs

1//! Shared helper: detect the enclosing git repo and append a path to
2//! its `.gitignore`.
3//!
4//! Used by both `memstead mem init` (legacy disk-mem bootstrap) and
5//! `memstead mem-repo init` (post-cutover mem-repo-git bootstrap). The two
6//! commands differ in *what* path they ignore (mem root vs. the
7//! `mem-repo/` directory) but share every other rule:
8//!
9//! - Walk upward from a starting directory looking for `.git/`.
10//! - Refuse to modify a `.gitignore` whose owning repo is `$HOME` —
11//!   silent edits to dotfile repos are a recognized footgun.
12//! - Stop the walk at filesystem root and at mount-boundary crossings
13//!   (the latter via `metadata().dev()` on unix; disabled elsewhere).
14//! - Append idempotently — if the ignore line is already present
15//!   (modulo leading/trailing slash), no change is made.
16//!
17//! Public surface: [`apply_outer_gitignore`] takes the start directory
18//! plus the path to ignore (which must be inside the outer repo for the
19//! relative computation to succeed); returns an [`OuterRepoOutcome`]
20//! the caller renders into a user-facing message.
21
22use std::fs;
23use std::io::Write;
24use std::path::{Path, PathBuf};
25
26use crate::CliError;
27use crate::output::ExitKind;
28
29/// Outcome of the outer-repo `.gitignore` handling step.
30#[derive(Debug)]
31pub enum OuterRepoOutcome {
32    /// A new line was appended to `<outer_root>/.gitignore`.
33    Appended { outer_root: PathBuf, rel: String },
34    /// An equivalent line already existed; no change.
35    AlreadyIgnored { outer_root: PathBuf, rel: String },
36    /// No outer git repo was found (or the walk crossed a mount).
37    NoOuter,
38    /// Caller passed `--no-gitignore` (or the equivalent suppression).
39    Skipped,
40}
41
42/// Walk upward from `start` (inclusive) looking for an outer `.git/`
43/// directory; on success append `ignore_path` (rendered relative to the
44/// outer root) to that repo's `.gitignore`. Callers start at the
45/// workspace root itself — the target's own gitdir lives below it
46/// (`mem-repo/.git`), never on the walk path — so a workspace that IS
47/// a git-repo root still gets its append.
48///
49/// `ignore_path` must lie inside the discovered outer repo for the
50/// relative-path rendering to succeed; if `strip_prefix` fails the
51/// helper returns [`OuterRepoOutcome::NoOuter`] defensively rather than
52/// emit a garbled rule.
53///
54/// Refuses with a `Validation` error when the discovered outer root
55/// equals `$HOME`. Callers are expected to render the error verbatim
56/// or surface the `--no-gitignore` suggestion themselves.
57pub fn apply_outer_gitignore(start: &Path, ignore_path: &Path) -> anyhow::Result<OuterRepoOutcome> {
58    let mut cursor = start.to_path_buf();
59    let start_dev = device_id(&cursor);
60
61    loop {
62        if cursor.join(".git").is_dir() {
63            let outer_root = cursor.clone();
64            let outer_dev = device_id(&outer_root);
65
66            if start_dev.is_some() && outer_dev != start_dev {
67                return Ok(OuterRepoOutcome::NoOuter);
68            }
69
70            return write_to_outer_gitignore(&outer_root, ignore_path);
71        }
72        match cursor.parent() {
73            Some(parent) => {
74                let parent_dev = device_id(parent);
75                if start_dev.is_some() && parent_dev != start_dev {
76                    return Ok(OuterRepoOutcome::NoOuter);
77                }
78                cursor = parent.to_path_buf();
79            }
80            None => return Ok(OuterRepoOutcome::NoOuter),
81        }
82    }
83}
84
85fn write_to_outer_gitignore(
86    outer_root: &Path,
87    ignore_path: &Path,
88) -> anyhow::Result<OuterRepoOutcome> {
89    if is_home_dir(outer_root) {
90        return Err(CliError {
91            code: "OUTER_GITIGNORE_HOME_REFUSED",
92            kind: ExitKind::Validation,
93            message: format!(
94                "detected outer git repo at {} which equals $HOME; refusing to \
95                 modify ~/.gitignore. Re-run with --no-gitignore (and edit \
96                 ~/.gitignore manually if desired) or place the target under \
97                 a different parent directory.",
98                outer_root.display()
99            ),
100            details: None,
101        }
102        .into());
103    }
104
105    let rel = match ignore_path.strip_prefix(outer_root) {
106        Ok(r) => format!("{}/", r.display()),
107        Err(_) => {
108            return Ok(OuterRepoOutcome::NoOuter);
109        }
110    };
111
112    let gitignore_path = outer_root.join(".gitignore");
113    let existing = fs::read_to_string(&gitignore_path).unwrap_or_default();
114
115    let needle = rel.trim_end_matches('/');
116    let already_ignored = existing.lines().any(|line| {
117        let t = line.trim().trim_start_matches('/').trim_end_matches('/');
118        t == needle
119    });
120
121    if already_ignored {
122        return Ok(OuterRepoOutcome::AlreadyIgnored {
123            outer_root: outer_root.to_path_buf(),
124            rel,
125        });
126    }
127
128    let mut block = String::new();
129    if !existing.is_empty() && !existing.ends_with('\n') {
130        block.push('\n');
131    }
132    if !existing.is_empty() {
133        block.push('\n');
134    }
135    block.push_str("# added by `memstead-cli`\n");
136    block.push_str(&rel);
137    block.push('\n');
138
139    let mut f = fs::OpenOptions::new()
140        .create(true)
141        .append(true)
142        .open(&gitignore_path)
143        .map_err(|e| CliError {
144            code: crate::INTERNAL_CODE,
145            kind: ExitKind::Generic,
146            message: format!("open outer .gitignore: {e}"),
147            details: None,
148        })?;
149    f.write_all(block.as_bytes()).map_err(|e| CliError {
150        code: crate::INTERNAL_CODE,
151        kind: ExitKind::Generic,
152        message: format!("append to outer .gitignore: {e}"),
153        details: None,
154    })?;
155
156    Ok(OuterRepoOutcome::Appended {
157        outer_root: outer_root.to_path_buf(),
158        rel,
159    })
160}
161
162fn is_home_dir(path: &Path) -> bool {
163    let Some(home) = dirs::home_dir() else {
164        return false;
165    };
166    let canon_home = fs::canonicalize(&home).unwrap_or(home);
167    let canon_path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
168    canon_path == canon_home
169}
170
171#[cfg(unix)]
172fn device_id(path: &Path) -> Option<u64> {
173    use std::os::unix::fs::MetadataExt;
174    fs::metadata(path).ok().map(|m| m.dev())
175}
176
177#[cfg(not(unix))]
178fn device_id(_path: &Path) -> Option<u64> {
179    None
180}