git-slop 0.16.4

A deterministic repository token-defragmenter for humans and AI agents.
Documentation
#[derive(Default)]
struct StatePromotion {
    moved: usize,
    retained: usize,
    stale: bool,
}

fn move_state_tree(source: &Path, destination: &Path, result: &mut StatePromotion) -> Result<()> {
    if !source.exists() {
        return Ok(());
    }
    fs::create_dir_all(destination)?;
    for entry in fs::read_dir(source)? {
        let entry = entry?;
        let from = entry.path();
        let to = destination.join(entry.file_name());
        if entry.file_type()?.is_dir() {
            move_state_tree(&from, &to, result)?;
            if fs::read_dir(&from)?.next().is_none() {
                fs::remove_dir(&from)?;
            }
        } else if to.exists() {
            result.retained += 1;
        } else {
            fs::rename(&from, &to)?;
            result.moved += 1;
        }
    }
    Ok(())
}

fn first_run_state_is_current(repo_root: &Path) -> bool {
    config::git_runtime_dir(repo_root)
        .ok()
        .map(|path| path.join("ephemeral/latest/report.json"))
        .filter(|path| path.is_file())
        .and_then(|path| report::load_report(&path).ok())
        .and_then(|report| crate::freshness::evaluate(repo_root, &report).ok())
        .is_some_and(|freshness| freshness.current)
}

fn promote_first_run_state(repo_root: &Path, eligible: bool) -> Result<StatePromotion> {
    let source = config::git_runtime_dir(repo_root)?.join("ephemeral");
    let report_path = source.join("latest/report.json");
    if !report_path.is_file() {
        return Ok(StatePromotion::default());
    }
    if !eligible {
        return Ok(StatePromotion {
            stale: true,
            ..StatePromotion::default()
        });
    }
    let mut result = StatePromotion::default();
    for directory in ["latest", "runs", "cache"] {
        move_state_tree(
            &source.join(directory),
            &config::slop_dir(repo_root).join(directory),
            &mut result,
        )?;
    }
    if source.is_dir() && fs::read_dir(&source)?.next().is_none() {
        fs::remove_dir(source)?;
    }
    Ok(result)
}

fn repository_owned_slop_paths(repo_root: &Path) -> Vec<String> {
    [
        ".slop/config.yaml",
        ".slop/.gitignore",
        ".slop/policies.yaml",
        ".slop/policy-lock.json",
    ]
    .into_iter()
    .filter(|path| repo_root.join(path).is_file())
    .map(str::to_string)
    .collect()
}

fn stage_command(paths: &[String]) -> Option<String> {
    (!paths.is_empty()).then(|| format!("git add {}", paths.join(" ")))
}

fn check_adoption_payload(repo_root: &Path, args: &InitArgs) -> (Value, bool) {
    let status = config::adoption_status(repo_root);
    let ready = if args.gitignore_only {
        status.gitignore_exists && status.missing_ignore_entries.is_empty()
    } else {
        status.ready()
    };
    let config_status = if args.gitignore_only {
        "skipped"
    } else if !status.config_exists {
        "missing"
    } else if status.config_valid {
        "valid"
    } else {
        "invalid"
    };
    let gitignore_status = if !status.gitignore_exists {
        "missing"
    } else if status.missing_ignore_entries.is_empty() {
        "current"
    } else {
        "repair_needed"
    };
    (
        json!({
            "config": {
                "path": ".slop/config.yaml",
                "status": config_status,
            },
            "gitignore": {
                "path": ".slop/.gitignore",
                "status": gitignore_status,
                "missing_entries": status.missing_ignore_entries,
            }
        }),
        ready,
    )
}

fn run_init(repo_root: &Path, args: InitArgs) -> Result<i32> {
    if args.check {
        let (adoption, ready) = check_adoption_payload(repo_root, &args);
        let staging_paths = repository_owned_slop_paths(repo_root);
        let staging_command = stage_command(&staging_paths);
        let repair_suffix = if args.gitignore_only {
            " --gitignore-only"
        } else {
            ""
        };
        let next_actions = if ready {
            vec!["git slop find".to_string()]
        } else {
            vec![
                format!("git slop init --repair{repair_suffix}"),
                format!("git slop init --check{repair_suffix}"),
            ]
        };
        let receipt = json!({
            "schema_version": 1,
            "command": "init",
            "mode": "check",
            "status": if ready { "ready" } else { "repair_needed" },
            "applied": false,
            "gitignore_only": args.gitignore_only,
            "mutation_class": "none",
            "durable_mutation": false,
            "actions": [],
            "changed_paths": [],
            "backups": [],
            "adoption": adoption.clone(),
            "promotion": {
                "status": "not_applicable",
                "moved_files": 0,
                "retained_files": 0,
            },
            "staging": {
                "required": false,
                "paths": staging_paths.clone(),
                "command": staging_command.clone(),
            },
            "rollback": {
                "status": "not_applicable",
                "guidance": "No changes were made.",
            },
            "next_actions": next_actions,
        });
        if args.format == InitFormat::Json {
            print_text(&render_json(&receipt)?);
            return Ok(if ready { 0 } else { 1 });
        }
        println!(
            "Adoption status: {}.",
            if ready { "ready" } else { "repair needed" }
        );
        if !args.gitignore_only {
            println!(
                "- config: {}",
                adoption["config"]["status"].as_str().unwrap_or("invalid")
            );
        }
        println!(
            "- ignore rules: {}",
            if adoption["gitignore"]["status"] == "missing" {
                "missing".to_string()
            } else if adoption["gitignore"]["status"] == "current" {
                "current".to_string()
            } else {
                format!(
                    "missing {}",
                    adoption["gitignore"]["missing_entries"]
                        .as_array()
                        .into_iter()
                        .flatten()
                        .filter_map(Value::as_str)
                        .collect::<Vec<_>>()
                        .join(", ")
                )
            }
        );
        if !ready {
            println!(
                "Next: run `git slop init --repair{repair_suffix}`, then `git slop init --check{repair_suffix}`."
            );
        }
        return Ok(if ready { 0 } else { 1 });
    }

    let was_ready = config::adoption_status(repo_root).ready();
    let first_run_current = !was_ready && first_run_state_is_current(repo_root);
    let result = config::initialize(repo_root, args.force, args.repair, args.gitignore_only)?;
    let mut changed_paths = Vec::new();
    let mut actions = Vec::new();
    if !args.gitignore_only {
        actions.push(json!({"path": ".slop/config.yaml", "action": result.config.clone()}));
        if matches!(result.config.as_str(), "written" | "replaced") {
            changed_paths.push(".slop/config.yaml".to_string());
        }
    }
    actions.push(json!({"path": ".slop/.gitignore", "action": result.gitignore.clone()}));
    if matches!(result.gitignore.as_str(), "written" | "replaced")
        || result.gitignore.starts_with("repaired")
    {
        changed_paths.push(".slop/.gitignore".to_string());
    }
    let promotion = if !was_ready && !args.gitignore_only {
        promote_first_run_state(repo_root, first_run_current)?
    } else {
        StatePromotion::default()
    };
    let promotion_status = if was_ready || args.gitignore_only {
        "not_applicable"
    } else if promotion.stale {
        "stale_retained"
    } else if promotion.moved > 0 && promotion.retained > 0 {
        "promoted_with_conflicts"
    } else if promotion.moved > 0 {
        "promoted"
    } else if promotion.retained > 0 {
        "conflicts_retained"
    } else {
        "no_state"
    };
    let backups = result
        .backups
        .iter()
        .map(|path| relative_display(path, repo_root))
        .collect::<Vec<_>>();
    let staging_paths = repository_owned_slop_paths(repo_root);
    let staging_command = stage_command(&staging_paths);
    let rollback_status = if backups.is_empty() {
        "version_control_or_remove_new_files"
    } else {
        "backups_available"
    };
    let rollback_guidance = if backups.is_empty() {
        "Review the changed paths, then restore tracked files from version control or remove newly created files."
    } else {
        "Review the ignored .bak files and restore the intended prior content before deleting any backup."
    };
    let mode = if args.force {
        "force"
    } else if args.repair {
        "repair"
    } else {
        "initialize"
    };
    let mut next_actions = Vec::new();
    if let Some(command) = &staging_command {
        next_actions.push(command.clone());
        next_actions.push("review and commit the staged repository-owned .slop files".to_string());
    }
    next_actions.push("git slop find".to_string());
    let receipt = json!({
        "schema_version": 1,
        "command": "init",
        "mode": mode,
        "status": "initialized",
        "applied": true,
        "gitignore_only": args.gitignore_only,
        "mutation_class": "repository_adoption",
        "durable_mutation": !changed_paths.is_empty() || promotion.moved > 0,
        "actions": actions,
        "changed_paths": changed_paths,
        "backups": backups,
        "adoption": check_adoption_payload(repo_root, &args).0,
        "promotion": {
            "status": promotion_status,
            "moved_files": promotion.moved,
            "retained_files": promotion.retained,
        },
        "staging": {
            "required": !staging_paths.is_empty(),
            "paths": staging_paths.clone(),
            "command": staging_command.clone(),
        },
        "rollback": {
            "status": rollback_status,
            "guidance": rollback_guidance,
        },
        "next_actions": next_actions,
    });
    if args.format == InitFormat::Json {
        print_text(&render_json(&receipt)?);
        return Ok(0);
    }
    if !args.gitignore_only {
        println!(
            "Initialized {} ({}).",
            relative_display(&config::config_path(repo_root), repo_root),
            result.config
        );
    }
    println!(
        "Initialized {} ({}).",
        relative_display(&config::slop_dir(repo_root).join(".gitignore"), repo_root),
        result.gitignore
    );
    println!("Ensured .slop/latest/, .slop/runs/, and .slop/cache/ exist.");
    for backup in &result.backups {
        println!(
            "Recovery backup: {}.",
            relative_display(backup, repo_root)
        );
    }
    if !was_ready && !args.gitignore_only {
        if promotion.stale {
            println!(
                "Retained Git-private first-run state because its report is stale; run `git slop find` after committing adoption."
            );
        } else if promotion.moved > 0 {
            println!(
                "Promoted {} compatible first-run state file(s) into .slop/.",
                promotion.moved
            );
            if promotion.retained > 0 {
                println!(
                    "Retained {} conflicting Git-private state file(s) for explicit review.",
                    promotion.retained
                );
            }
        }
    }
    let adoption_paths = staging_paths
        .iter()
        .map(|path| format!("`{path}`"))
        .collect::<Vec<_>>()
        .join(", ");
    println!("Next: review and commit {adoption_paths}.");
    if let Some(command) = stage_command(&staging_paths) {
        println!("Stage adoption with `{command}`.");
    }
    println!(
        "Then run `git slop find` (or `git slop find --ephemeral` for a disposable scan)."
    );
    Ok(0)
}