xbp 10.38.2

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, stash, or revert first (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, or revert first. 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> {
    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 state.is_dirty {
        registry.entries.insert(
            entry_key,
            VersionChangeGuardEntry {
                pending_version_change_count: 1,
                head_commit: state.head_commit,
            },
        );
    } else {
        registry.entries.remove(&entry_key);
    }

    save_version_change_guard_registry(&state_path, &registry)
}