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` looking for an outer `.git/` directory; on
43/// success append `ignore_path` (rendered relative to the outer root)
44/// to that repo's `.gitignore`. The starting directory is the parent of
45/// the new gitdir / mem-repo so we don't rediscover our own git.
46///
47/// `ignore_path` must lie inside the discovered outer repo for the
48/// relative-path rendering to succeed; if `strip_prefix` fails the
49/// helper returns [`OuterRepoOutcome::NoOuter`] defensively rather than
50/// emit a garbled rule.
51///
52/// Refuses with a `Validation` error when the discovered outer root
53/// equals `$HOME`. Callers are expected to render the error verbatim
54/// or surface the `--no-gitignore` suggestion themselves.
55pub fn apply_outer_gitignore(start: &Path, ignore_path: &Path) -> anyhow::Result<OuterRepoOutcome> {
56    let mut cursor = start.to_path_buf();
57    let start_dev = device_id(&cursor);
58
59    loop {
60        if cursor.join(".git").is_dir() {
61            let outer_root = cursor.clone();
62            let outer_dev = device_id(&outer_root);
63
64            if start_dev.is_some() && outer_dev != start_dev {
65                return Ok(OuterRepoOutcome::NoOuter);
66            }
67
68            return write_to_outer_gitignore(&outer_root, ignore_path);
69        }
70        match cursor.parent() {
71            Some(parent) => {
72                let parent_dev = device_id(parent);
73                if start_dev.is_some() && parent_dev != start_dev {
74                    return Ok(OuterRepoOutcome::NoOuter);
75                }
76                cursor = parent.to_path_buf();
77            }
78            None => return Ok(OuterRepoOutcome::NoOuter),
79        }
80    }
81}
82
83fn write_to_outer_gitignore(
84    outer_root: &Path,
85    ignore_path: &Path,
86) -> anyhow::Result<OuterRepoOutcome> {
87    if is_home_dir(outer_root) {
88        return Err(CliError {
89            code: "OUTER_GITIGNORE_HOME_REFUSED",
90            kind: ExitKind::Validation,
91            message: format!(
92                "detected outer git repo at {} which equals $HOME; refusing to \
93                 modify ~/.gitignore. Re-run with --no-gitignore (and edit \
94                 ~/.gitignore manually if desired) or place the target under \
95                 a different parent directory.",
96                outer_root.display()
97            ),
98            details: None,
99        }
100        .into());
101    }
102
103    let rel = match ignore_path.strip_prefix(outer_root) {
104        Ok(r) => format!("{}/", r.display()),
105        Err(_) => {
106            return Ok(OuterRepoOutcome::NoOuter);
107        }
108    };
109
110    let gitignore_path = outer_root.join(".gitignore");
111    let existing = fs::read_to_string(&gitignore_path).unwrap_or_default();
112
113    let needle = rel.trim_end_matches('/');
114    let already_ignored = existing.lines().any(|line| {
115        let t = line.trim().trim_start_matches('/').trim_end_matches('/');
116        t == needle
117    });
118
119    if already_ignored {
120        return Ok(OuterRepoOutcome::AlreadyIgnored {
121            outer_root: outer_root.to_path_buf(),
122            rel,
123        });
124    }
125
126    let mut block = String::new();
127    if !existing.is_empty() && !existing.ends_with('\n') {
128        block.push('\n');
129    }
130    if !existing.is_empty() {
131        block.push('\n');
132    }
133    block.push_str("# added by `memstead-cli`\n");
134    block.push_str(&rel);
135    block.push('\n');
136
137    let mut f = fs::OpenOptions::new()
138        .create(true)
139        .append(true)
140        .open(&gitignore_path)
141        .map_err(|e| CliError {
142            code: crate::INTERNAL_CODE,
143            kind: ExitKind::Generic,
144            message: format!("open outer .gitignore: {e}"),
145            details: None,
146        })?;
147    f.write_all(block.as_bytes()).map_err(|e| CliError {
148        code: crate::INTERNAL_CODE,
149        kind: ExitKind::Generic,
150        message: format!("append to outer .gitignore: {e}"),
151        details: None,
152    })?;
153
154    Ok(OuterRepoOutcome::Appended {
155        outer_root: outer_root.to_path_buf(),
156        rel,
157    })
158}
159
160fn is_home_dir(path: &Path) -> bool {
161    let Some(home) = dirs::home_dir() else {
162        return false;
163    };
164    let canon_home = fs::canonicalize(&home).unwrap_or(home);
165    let canon_path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
166    canon_path == canon_home
167}
168
169#[cfg(unix)]
170fn device_id(path: &Path) -> Option<u64> {
171    use std::os::unix::fs::MetadataExt;
172    fs::metadata(path).ok().map(|m| m.dev())
173}
174
175#[cfg(not(unix))]
176fn device_id(_path: &Path) -> Option<u64> {
177    None
178}