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> {
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}