use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::CliError;
use crate::output::ExitKind;
#[derive(Debug)]
pub enum OuterRepoOutcome {
Appended { outer_root: PathBuf, rel: String },
AlreadyIgnored { outer_root: PathBuf, rel: String },
NoOuter,
Skipped,
}
pub fn apply_outer_gitignore(start: &Path, ignore_path: &Path) -> anyhow::Result<OuterRepoOutcome> {
let mut cursor = start.to_path_buf();
let start_dev = device_id(&cursor);
loop {
if cursor.join(".git").is_dir() {
let outer_root = cursor.clone();
let outer_dev = device_id(&outer_root);
if start_dev.is_some() && outer_dev != start_dev {
return Ok(OuterRepoOutcome::NoOuter);
}
return write_to_outer_gitignore(&outer_root, ignore_path);
}
match cursor.parent() {
Some(parent) => {
let parent_dev = device_id(parent);
if start_dev.is_some() && parent_dev != start_dev {
return Ok(OuterRepoOutcome::NoOuter);
}
cursor = parent.to_path_buf();
}
None => return Ok(OuterRepoOutcome::NoOuter),
}
}
}
fn write_to_outer_gitignore(
outer_root: &Path,
ignore_path: &Path,
) -> anyhow::Result<OuterRepoOutcome> {
if is_home_dir(outer_root) {
return Err(CliError {
code: "OUTER_GITIGNORE_HOME_REFUSED",
kind: ExitKind::Validation,
message: format!(
"detected outer git repo at {} which equals $HOME; refusing to \
modify ~/.gitignore. Re-run with --no-gitignore (and edit \
~/.gitignore manually if desired) or place the target under \
a different parent directory.",
outer_root.display()
),
details: None,
}
.into());
}
let rel = match ignore_path.strip_prefix(outer_root) {
Ok(r) => format!("{}/", r.display()),
Err(_) => {
return Ok(OuterRepoOutcome::NoOuter);
}
};
let gitignore_path = outer_root.join(".gitignore");
let existing = fs::read_to_string(&gitignore_path).unwrap_or_default();
let needle = rel.trim_end_matches('/');
let already_ignored = existing.lines().any(|line| {
let t = line.trim().trim_start_matches('/').trim_end_matches('/');
t == needle
});
if already_ignored {
return Ok(OuterRepoOutcome::AlreadyIgnored {
outer_root: outer_root.to_path_buf(),
rel,
});
}
let mut block = String::new();
if !existing.is_empty() && !existing.ends_with('\n') {
block.push('\n');
}
if !existing.is_empty() {
block.push('\n');
}
block.push_str("# added by `memstead-cli`\n");
block.push_str(&rel);
block.push('\n');
let mut f = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&gitignore_path)
.map_err(|e| CliError {
code: crate::INTERNAL_CODE,
kind: ExitKind::Generic,
message: format!("open outer .gitignore: {e}"),
details: None,
})?;
f.write_all(block.as_bytes()).map_err(|e| CliError {
code: crate::INTERNAL_CODE,
kind: ExitKind::Generic,
message: format!("append to outer .gitignore: {e}"),
details: None,
})?;
Ok(OuterRepoOutcome::Appended {
outer_root: outer_root.to_path_buf(),
rel,
})
}
fn is_home_dir(path: &Path) -> bool {
let Some(home) = dirs::home_dir() else {
return false;
};
let canon_home = fs::canonicalize(&home).unwrap_or(home);
let canon_path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
canon_path == canon_home
}
#[cfg(unix)]
fn device_id(path: &Path) -> Option<u64> {
use std::os::unix::fs::MetadataExt;
fs::metadata(path).ok().map(|m| m.dev())
}
#[cfg(not(unix))]
fn device_id(_path: &Path) -> Option<u64> {
None
}