xbp 10.57.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! Dirty-worktree version-change guard (per-scope isolation).

use crate::config::global_xbp_paths;
use std::fs;
use std::path::{Path, PathBuf};

use super::git_ops::git_worktree_state;
use super::scope::{version_scope_guard_id, version_scope_prompt_label};
use super::types::{GitWorktreeState, VersionChangeGuardEntry, VersionChangeGuardRegistry};
use super::VersionScope;

const VERSION_CHANGE_GUARD_FILE_NAME: &str = "version-change-guard.yaml";

pub(crate) fn version_change_guard_state_path() -> Result<PathBuf, String> {
    let paths = global_xbp_paths()?;
    Ok(paths.cache_dir.join(VERSION_CHANGE_GUARD_FILE_NAME))
}

pub(crate) fn version_change_guard_repo_key(project_root: &Path) -> String {
    fs::canonicalize(project_root)
        .unwrap_or_else(|_| project_root.to_path_buf())
        .to_string_lossy()
        .replace('\\', "/")
}

pub(crate) fn version_change_guard_entry_key(project_root: &Path, scope: Option<&VersionScope>) -> String {
    let repo = version_change_guard_repo_key(project_root);
    match scope {
        Some(scope) => format!("{repo}::{}", version_scope_guard_id(scope)),
        None => format!("{repo}::repository"),
    }
}

pub(crate) fn load_version_change_guard_registry(path: &Path) -> Result<VersionChangeGuardRegistry, String> {
    if !path.exists() {
        return Ok(VersionChangeGuardRegistry::default());
    }

    let content = fs::read_to_string(path).map_err(|e| {
        format!(
            "Failed to read version-change guard state {}: {}",
            path.display(),
            e
        )
    })?;

    Ok(serde_yaml::from_str::<VersionChangeGuardRegistry>(&content).unwrap_or_default())
}

pub(crate) fn save_version_change_guard_registry(
    path: &Path,
    registry: &VersionChangeGuardRegistry,
) -> Result<(), String> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).map_err(|e| {
            format!(
                "Failed to create guard state directory {}: {}",
                parent.display(),
                e
            )
        })?;
    }

    let content = serde_yaml::to_string(registry)
        .map_err(|e| format!("Failed to serialize version-change guard state: {}", e))?;
    fs::write(path, content).map_err(|e| {
        format!(
            "Failed to write version-change guard state {}: {}",
            path.display(),
            e
        )
    })
}

pub(crate) fn should_clear_version_change_guard(
    entry: &VersionChangeGuardEntry,
    state: &GitWorktreeState,
) -> bool {
    if entry.pending_version_change_count == 0 {
        return true;
    }
    if !state.is_dirty {
        return true;
    }

    match (&entry.head_commit, &state.head_commit) {
        (Some(previous), Some(current)) => previous != current,
        (Some(_), None) => true,
        _ => false,
    }
}

pub(crate) fn enforce_version_change_guard(
    project_root: &Path,
    scope: Option<&VersionScope>,
) -> Result<(), String> {
    let Some(state) = git_worktree_state(project_root)? else {
        return Ok(());
    };

    let state_path: PathBuf = version_change_guard_state_path()?;
    let mut registry: VersionChangeGuardRegistry = load_version_change_guard_registry(&state_path)?;
    let repo_key: String = version_change_guard_repo_key(project_root);
    let entry_key = version_change_guard_entry_key(project_root, scope);
    let mut changed = false;

    // Clear stale entries for this repo (scoped + legacy unscoped keys).
    let keys_to_check = registry
        .entries
        .keys()
        .filter(|key| {
            *key == &repo_key || key.starts_with(&format!("{repo_key}::")) || *key == &entry_key
        })
        .cloned()
        .collect::<Vec<_>>();
    for key in keys_to_check {
        if let Some(entry) = registry.entries.get(&key).cloned() {
            if should_clear_version_change_guard(&entry, &state) {
                registry.entries.remove(&key);
                changed = true;
            }
        }
    }

    if changed {
        save_version_change_guard_registry(&state_path, &registry)?;
    }

    if state.is_dirty {
        // Per-scope isolation: only block re-versioning the *same* scope while dirty.
        // Other services/projects in the same dirty worktree may still version independently.
        if let Some(entry) = registry.entries.get(&entry_key) {
            if entry.pending_version_change_count >= 1 {
                let scope_label = scope
                    .map(version_scope_prompt_label)
                    .unwrap_or_else(|| "Repository".to_string());
                return Err(format!(
                    "Cannot run another version change for `{scope_label}` on a dirty worktree: pending version-change count is {}. Commit or stash the previous version write for this scope first (or re-run `xbp v bump` and re-select it — multi-scope bumps clear intentional re-selections). Other services remain free to version independently. Guard state: {}",
                    entry.pending_version_change_count,
                    state_path.display()
                ));
            }
        }
        // Legacy repo-wide guard (pre-isolation): still honor if present.
        if let Some(entry) = registry.entries.get(&repo_key) {
            if entry.pending_version_change_count >= 1 {
                return Err(format!(
                    "Cannot run another version change on a dirty worktree: pending version-change count is {}. Commit/stash first, or re-select packages in `xbp v bump` to clear intentional re-bumps. Guard state: {}",
                    entry.pending_version_change_count,
                    state_path.display()
                ));
            }
        }
    }

    Ok(())
}

pub(crate) fn record_version_change_guard(
    project_root: &Path,
    scope: Option<&VersionScope>,
) -> Result<(), String> {
    // Legacy callers: only arm the guard when the worktree is dirty (treat as
    // uncommitted version write). Prefer [`record_version_change_guard_after_write`].
    record_version_change_guard_after_write(project_root, scope, false)
}

/// Update the pending version-change guard after a version write + auto-commit attempt.
///
/// - `version_change_committed = true` → always clear this scope (HEAD advanced or
///   the change landed). Leftover dirty files from *other* scopes must not block
///   the next multi-package `xbp v bump`.
/// - `version_change_committed = false` and dirty → arm pending so a double-bump
///   of the same scope without commit is blocked.
pub(crate) fn record_version_change_guard_after_write(
    project_root: &Path,
    scope: Option<&VersionScope>,
    version_change_committed: bool,
) -> Result<(), String> {
    let Some(state) = git_worktree_state(project_root)? else {
        return Ok(());
    };

    let state_path: PathBuf = version_change_guard_state_path()?;
    let mut registry: VersionChangeGuardRegistry = load_version_change_guard_registry(&state_path)?;
    let entry_key = version_change_guard_entry_key(project_root, scope);

    if version_change_committed || !state.is_dirty {
        registry.entries.remove(&entry_key);
    } else {
        registry.entries.insert(
            entry_key,
            VersionChangeGuardEntry {
                pending_version_change_count: 1,
                head_commit: state.head_commit,
            },
        );
    }

    save_version_change_guard_registry(&state_path, &registry)
}

/// Drop pending guards for scopes the operator just selected to bump again.
///
/// Multi-package interactive bump must not fail the whole batch because a prior
/// partial bump left `pending=1` for one service (e.g. root `xbp-legacy`).
pub(crate) fn clear_version_change_guards_for_scopes(
    project_root: &Path,
    scopes: &[&VersionScope],
) -> Result<(), String> {
    if scopes.is_empty() {
        return Ok(());
    }
    let state_path: PathBuf = version_change_guard_state_path()?;
    let mut registry: VersionChangeGuardRegistry = load_version_change_guard_registry(&state_path)?;
    let mut changed = false;
    for scope in scopes {
        let key = version_change_guard_entry_key(project_root, Some(scope));
        if registry.entries.remove(&key).is_some() {
            changed = true;
        }
    }
    // Also clear legacy unscoped repo key when any repository-root scope is selected.
    let repo_key = version_change_guard_repo_key(project_root);
    if scopes.iter().any(|s| {
        matches!(s, VersionScope::Repository)
            || matches!(
                s,
                VersionScope::Service {
                    service_relative_root,
                    ..
                } if {
                    let r = service_relative_root.replace('\\', "/");
                    let r = r.trim().trim_start_matches("./");
                    r.is_empty() || r == "."
                }
            )
    }) && registry.entries.remove(&repo_key).is_some()
    {
        changed = true;
    }
    if changed {
        save_version_change_guard_registry(&state_path, &registry)?;
    }
    Ok(())
}

/// Soft-check: true when this scope is currently blocked by a pending dirty guard.
pub(crate) fn version_change_guard_blocks(
    project_root: &Path,
    scope: Option<&VersionScope>,
) -> Result<bool, String> {
    match enforce_version_change_guard(project_root, scope) {
        Ok(()) => Ok(false),
        Err(error) if error.contains("pending version-change") => Ok(true),
        Err(error) => Err(error),
    }
}