git-slop 0.16.4

A deterministic repository token-defragmenter for humans and AI agents.
Documentation
#[derive(Debug, Clone)]
pub struct InitResult {
    pub config: String,
    pub gitignore: String,
    pub backups: Vec<PathBuf>,
}

#[derive(Debug, Clone)]
pub struct AdoptionStatus {
    pub config_exists: bool,
    pub config_valid: bool,
    pub gitignore_exists: bool,
    pub missing_ignore_entries: Vec<String>,
}

impl AdoptionStatus {
    pub fn ready(&self) -> bool {
        self.config_exists
            && self.config_valid
            && self.gitignore_exists
            && self.missing_ignore_entries.is_empty()
    }
}

fn required_ignore_entries() -> Vec<&'static str> {
    DEFAULT_SLOP_GITIGNORE
        .lines()
        .filter(|line| !line.trim().is_empty())
        .collect()
}

pub fn adoption_status(repo_root: &Path) -> AdoptionStatus {
    let config_target = config_path(repo_root);
    let gitignore_target = slop_dir(repo_root).join(".gitignore");
    let gitignore = fs::read_to_string(&gitignore_target).unwrap_or_default();
    let present = gitignore
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty())
        .collect::<std::collections::BTreeSet<_>>();
    AdoptionStatus {
        config_exists: config_target.is_file(),
        config_valid: config_target.is_file() && load(repo_root).is_ok(),
        gitignore_exists: gitignore_target.is_file(),
        missing_ignore_entries: required_ignore_entries()
            .into_iter()
            .filter(|entry| !present.contains(entry))
            .map(ToOwned::to_owned)
            .collect(),
    }
}

pub fn initialize(
    repo_root: &Path,
    force: bool,
    repair: bool,
    gitignore_only: bool,
) -> Result<InitResult> {
    ensure_state_dirs(repo_root)?;
    let mut backups = Vec::new();
    let config_target = config_path(repo_root);
    let config_status = if gitignore_only {
        "skipped".to_string()
    } else if force || !config_target.exists() {
        if let Some(backup) = write_text_atomically(&config_target, MINIMAL_CONFIG, force)? {
            backups.push(backup);
        }
        if force { "replaced" } else { "written" }.to_string()
    } else {
        "kept".to_string()
    };
    let gitignore_target = slop_dir(repo_root).join(".gitignore");
    let gitignore_status = if force || !gitignore_target.exists() {
        if let Some(backup) =
            write_text_atomically(&gitignore_target, DEFAULT_SLOP_GITIGNORE, force)?
        {
            backups.push(backup);
        }
        if force { "replaced" } else { "written" }.to_string()
    } else if repair {
        let current = fs::read_to_string(&gitignore_target)?;
        let present = current
            .lines()
            .map(str::trim)
            .filter(|line| !line.is_empty())
            .collect::<std::collections::BTreeSet<_>>();
        let missing = required_ignore_entries()
            .into_iter()
            .filter(|entry| !present.contains(entry))
            .collect::<Vec<_>>();
        if missing.is_empty() {
            "current".to_string()
        } else {
            let mut repaired = current;
            if !repaired.is_empty() && !repaired.ends_with('\n') {
                repaired.push('\n');
            }
            for entry in &missing {
                repaired.push_str(entry);
                repaired.push('\n');
            }
            if let Some(backup) = write_text_atomically(&gitignore_target, repaired, true)? {
                backups.push(backup);
            }
            format!("repaired (added {})", missing.len())
        }
    } else {
        "kept".to_string()
    };
    Ok(InitResult {
        config: config_status,
        gitignore: gitignore_status,
        backups,
    })
}