use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use crate::fs::errors::{FsError, FsResult};
use crate::fs::staging::{Staging, StagingState};
use crate::repo::Repository;
pub const DEFAULT_COMMIT_INTERVAL: Duration = Duration::from_secs(60);
pub const MIN_COMMIT_INTERVAL: Duration = Duration::from_secs(5);
#[derive(Debug, Clone)]
pub struct CommitConfig {
pub interval: Duration,
pub lock_timeout: Duration,
pub commit_on_shutdown: bool,
}
impl Default for CommitConfig {
fn default() -> Self {
Self {
interval: DEFAULT_COMMIT_INTERVAL,
lock_timeout: Duration::from_secs(30),
commit_on_shutdown: true,
}
}
}
pub struct CommitTimer {
handle: Option<JoinHandle<()>>,
stop_signal: Arc<AtomicBool>,
commit_due: Arc<AtomicBool>,
force_commit: Arc<AtomicBool>,
#[allow(dead_code)]
config: CommitConfig,
}
impl CommitTimer {
pub fn start(config: CommitConfig) -> Self {
let stop_signal = Arc::new(AtomicBool::new(false));
let commit_due = Arc::new(AtomicBool::new(false));
let force_commit = Arc::new(AtomicBool::new(false));
let stop_clone = Arc::clone(&stop_signal);
let commit_due_clone = Arc::clone(&commit_due);
let force_clone = Arc::clone(&force_commit);
let interval = config.interval;
let handle = thread::Builder::new()
.name("heroforge-commit-timer".to_string())
.spawn(move || {
Self::timer_loop(stop_clone, commit_due_clone, force_clone, interval);
})
.expect("Failed to spawn commit timer thread");
Self {
handle: Some(handle),
stop_signal,
commit_due,
force_commit,
config,
}
}
fn timer_loop(
stop_signal: Arc<AtomicBool>,
commit_due: Arc<AtomicBool>,
force_commit: Arc<AtomicBool>,
interval: Duration,
) {
let sleep_interval = Duration::from_millis(100);
let mut elapsed = Duration::ZERO;
loop {
if stop_signal.load(Ordering::SeqCst) {
commit_due.store(true, Ordering::SeqCst);
break;
}
if force_commit.swap(false, Ordering::SeqCst) {
commit_due.store(true, Ordering::SeqCst);
elapsed = Duration::ZERO;
}
if elapsed >= interval {
commit_due.store(true, Ordering::SeqCst);
elapsed = Duration::ZERO;
}
thread::sleep(sleep_interval);
elapsed += sleep_interval;
}
}
pub fn try_commit(&self, staging: &Staging, repo: &Repository) -> FsResult<Option<String>> {
if !self.commit_due.swap(false, Ordering::SeqCst) {
return Ok(None);
}
let result = commit_now(staging, repo)?;
if result == "no-changes" {
Ok(None)
} else {
Ok(Some(result))
}
}
pub fn force_commit(&self) {
self.force_commit.store(true, Ordering::SeqCst);
}
pub fn is_commit_due(&self) -> bool {
self.commit_due.load(Ordering::SeqCst)
}
pub fn stop(&mut self) {
self.stop_signal.store(true, Ordering::SeqCst);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
pub fn is_running(&self) -> bool {
self.handle
.as_ref()
.map(|h| !h.is_finished())
.unwrap_or(false)
}
}
impl Drop for CommitTimer {
fn drop(&mut self) {
self.stop();
}
}
pub type CommitWorker = CommitTimer;
impl CommitWorker {
pub fn start_legacy(
_staging: Staging,
_repo: Arc<Repository>,
config: CommitConfig,
) -> FsResult<Self> {
Ok(CommitTimer::start(config))
}
}
fn do_commit(state: &mut StagingState, repo: &Repository) -> FsResult<String> {
let author = state.author().to_string();
let branch = state.branch().to_string();
let mut staged_files: Vec<(String, Vec<u8>)> = Vec::new();
let mut deletions: std::collections::HashSet<String> = std::collections::HashSet::new();
for (path, staged_file) in state.files() {
if staged_file.is_deleted {
deletions.insert(path.clone());
} else if staged_file.modified {
let content = state.read_file(path)?;
staged_files.push((path.clone(), content));
}
}
if staged_files.is_empty() && deletions.is_empty() {
state.mark_clean();
return Ok("no-changes".to_string());
}
let parent_hash = repo
.branches()
.get(&branch)
.ok()
.and_then(|b| b.tip().ok())
.map(|c| c.hash);
let mut files_to_commit: Vec<(String, Vec<u8>)> = Vec::new();
let staged_paths: std::collections::HashSet<String> =
staged_files.iter().map(|(p, _)| p.clone()).collect();
if let Some(ref parent) = parent_hash {
if let Ok(parent_files) = repo.list_files_internal(parent) {
for file_info in parent_files {
if deletions.contains(&file_info.name) {
continue;
}
if staged_paths.contains(&file_info.name) {
continue;
}
if let Ok(content) = repo.read_file_internal(parent, &file_info.name) {
files_to_commit.push((file_info.name, content));
}
}
}
}
files_to_commit.extend(staged_files);
let deletions_vec: Vec<String> = deletions.iter().cloned().collect();
let message = build_commit_message(&files_to_commit, &deletions_vec);
let files_refs: Vec<(&str, &[u8])> = files_to_commit
.iter()
.map(|(p, c)| (p.as_str(), c.as_slice()))
.collect();
let commit_hash = repo
.commit_internal(
&files_refs,
&message,
&author,
parent_hash.as_deref(),
Some(&branch),
)
.map_err(|e| FsError::DatabaseError(format!("Commit failed: {}", e)))?;
state.clear()?;
Ok(commit_hash)
}
fn build_commit_message(files: &[(String, Vec<u8>)], deletions: &[String]) -> String {
let mut parts = Vec::new();
if !files.is_empty() {
if files.len() == 1 {
parts.push(format!("Update {}", files[0].0));
} else {
parts.push(format!("Update {} files", files.len()));
}
}
if !deletions.is_empty() {
if deletions.len() == 1 {
parts.push(format!("Delete {}", deletions[0]));
} else {
parts.push(format!("Delete {} files", deletions.len()));
}
}
if parts.is_empty() {
"Auto-commit".to_string()
} else {
parts.join(", ")
}
}
pub fn commit_now(staging: &Staging, repo: &Repository) -> FsResult<String> {
let mut state = staging.write();
if !state.is_dirty() {
return Ok("no-changes".to_string());
}
do_commit(&mut state, repo)
}
pub fn force_commit_sync(staging: &Staging, repo: &Repository) -> FsResult<String> {
commit_now(staging, repo)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_commit_config_default() {
let config = CommitConfig::default();
assert_eq!(config.interval, DEFAULT_COMMIT_INTERVAL);
assert!(config.commit_on_shutdown);
}
#[test]
fn test_build_commit_message() {
let files = vec![
("file1.txt".to_string(), vec![1, 2, 3]),
("file2.txt".to_string(), vec![4, 5, 6]),
];
let deletions = vec!["old.txt".to_string()];
let msg = build_commit_message(&files, &deletions);
assert!(msg.contains("Update 2 files"));
assert!(msg.contains("Delete old.txt"));
}
#[test]
fn test_build_commit_message_single_file() {
let files = vec![("readme.md".to_string(), vec![1, 2, 3])];
let deletions: Vec<String> = vec![];
let msg = build_commit_message(&files, &deletions);
assert_eq!(msg, "Update readme.md");
}
#[test]
fn test_commit_timer_creation() {
let config = CommitConfig::default();
let mut timer = CommitTimer::start(config);
assert!(timer.is_running());
timer.stop();
}
}