git-shadow 0.0.3

Standalone shadow copies of git repos (or parts of them) in a working directory.
use std::process::ExitCode;

use crate::config::{RepoContext, config_without_shadow, parse_config};
use crate::exclude::{exclude_entry, remove_mapping_exclusion};
use crate::paths::normalize_lexically;

pub fn run(name: &str, delete: bool) -> ExitCode {
    match remove_shadow(name, delete) {
        Ok(()) => ExitCode::SUCCESS,
        Err(e) => {
            eprintln!("git-shadow: {e}");
            ExitCode::FAILURE
        }
    }
}

/// Removes a shadow from the config and the exclude file. The mapping
/// directory is left on disk unless `delete` is set.
fn remove_shadow(name: &str, delete: bool) -> Result<(), String> {
    let ctx = RepoContext::discover()?;

    if !ctx.config_path.exists() {
        return Err(format!(
            "shadow '{name}' not found; no config at {}",
            ctx.config_path.display()
        ));
    }
    let content = std::fs::read_to_string(&ctx.config_path)
        .map_err(|e| format!("failed to read {}: {e}", ctx.config_path.display()))?;
    let config = parse_config(&content)
        .map_err(|e| format!("failed to parse {}: {e}", ctx.config_path.display()))?;

    let shadow = config
        .shadows
        .get(name)
        .ok_or_else(|| format!("shadow '{name}' not found in {}", ctx.config_path.display()))?;
    let mapping = shadow.mapping.clone();

    let new_content = config_without_shadow(&content, name)?;
    let new_config = parse_config(&new_content)
        .map_err(|e| format!("refusing to write an invalid config: {e}"))?;
    if new_config.shadows.len() != config.shadows.len() - 1 || new_config.shadows.contains_key(name)
    {
        return Err(format!(
            "refusing to write config: removal would not drop exactly shadow '{name}'"
        ));
    }

    std::fs::write(&ctx.config_path, new_content)
        .map_err(|e| format!("failed to write {}: {e}", ctx.config_path.display()))?;
    println!("removed shadow '{name}' from {}", ctx.config_path.display());

    match remove_mapping_exclusion(&ctx.parent_repo, &mapping) {
        Ok(Some(exclude_path)) => {
            println!(
                "removed '{}' from {}",
                exclude_entry(&mapping),
                exclude_path.display()
            );
        }
        Ok(None) => {}
        Err(e) => {
            return Err(format!(
                "shadow was removed from config, but updating git exclude failed: {e}"
            ));
        }
    }

    if delete {
        let dir = normalize_lexically(&ctx.parent_repo.join(&mapping));
        let parent = normalize_lexically(&ctx.parent_repo);
        if !dir.starts_with(&parent) || dir == parent {
            return Err(format!(
                "refusing to delete {}: it is not inside the parent repository",
                dir.display()
            ));
        }
        if dir.exists() {
            std::fs::remove_dir_all(&dir)
                .map_err(|e| format!("failed to delete {}: {e}", dir.display()))?;
            println!("deleted {}", dir.display());
        }
    }

    Ok(())
}