memstead_cli/
outer_gitignore.rs1use std::fs;
23use std::io::Write;
24use std::path::{Path, PathBuf};
25
26use crate::CliError;
27use crate::output::ExitKind;
28
29#[derive(Debug)]
31pub enum OuterRepoOutcome {
32 Appended { outer_root: PathBuf, rel: String },
34 AlreadyIgnored { outer_root: PathBuf, rel: String },
36 NoOuter,
38 Skipped,
40}
41
42pub 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}