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;
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, ®istry)?;
}
if state.is_dirty {
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()
));
}
}
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, ®istry)
}