heroforge_core/fs/
commit_thread.rs

1//! Background commit timer and synchronous commit helpers.
2//!
3//! Due to SQLite's threading constraints (rusqlite::Connection is not Sync),
4//! we use a timer-based approach where:
5//! - A background thread tracks elapsed time
6//! - When commit time arrives, it sets a flag
7//! - The actual commit is performed synchronously by the caller
8//!
9//! This module provides:
10//! - `CommitTimer` - Background timer that signals when commits are due
11//! - `commit_now` - Synchronous commit helper
12//! - `CommitConfig` - Configuration for commit behavior
13
14use std::sync::Arc;
15use std::sync::atomic::{AtomicBool, Ordering};
16use std::thread::{self, JoinHandle};
17use std::time::Duration;
18
19use crate::fs::errors::{FsError, FsResult};
20use crate::fs::staging::{Staging, StagingState};
21use crate::repo::Repository;
22
23/// Default commit interval (1 minute)
24pub const DEFAULT_COMMIT_INTERVAL: Duration = Duration::from_secs(60);
25
26/// Minimum commit interval
27pub const MIN_COMMIT_INTERVAL: Duration = Duration::from_secs(5);
28
29/// Commit worker configuration
30#[derive(Debug, Clone)]
31pub struct CommitConfig {
32    /// Interval between automatic commits
33    pub interval: Duration,
34
35    /// Maximum time to wait for lock before skipping commit
36    pub lock_timeout: Duration,
37
38    /// Whether to commit on shutdown
39    pub commit_on_shutdown: bool,
40}
41
42impl Default for CommitConfig {
43    fn default() -> Self {
44        Self {
45            interval: DEFAULT_COMMIT_INTERVAL,
46            lock_timeout: Duration::from_secs(30),
47            commit_on_shutdown: true,
48        }
49    }
50}
51
52/// A timer that signals when commits are due.
53///
54/// This runs a background thread that sets a flag when the commit interval elapses.
55/// The actual commit must be performed by calling `try_commit()` with the repository.
56pub struct CommitTimer {
57    /// Thread handle
58    handle: Option<JoinHandle<()>>,
59
60    /// Signal to stop the timer
61    stop_signal: Arc<AtomicBool>,
62
63    /// Signal that a commit is due
64    commit_due: Arc<AtomicBool>,
65
66    /// Signal that a forced commit is requested
67    force_commit: Arc<AtomicBool>,
68
69    /// Configuration (kept for future use)
70    #[allow(dead_code)]
71    config: CommitConfig,
72}
73
74impl CommitTimer {
75    /// Start a new commit timer
76    pub fn start(config: CommitConfig) -> Self {
77        let stop_signal = Arc::new(AtomicBool::new(false));
78        let commit_due = Arc::new(AtomicBool::new(false));
79        let force_commit = Arc::new(AtomicBool::new(false));
80
81        let stop_clone = Arc::clone(&stop_signal);
82        let commit_due_clone = Arc::clone(&commit_due);
83        let force_clone = Arc::clone(&force_commit);
84        let interval = config.interval;
85
86        let handle = thread::Builder::new()
87            .name("heroforge-commit-timer".to_string())
88            .spawn(move || {
89                Self::timer_loop(stop_clone, commit_due_clone, force_clone, interval);
90            })
91            .expect("Failed to spawn commit timer thread");
92
93        Self {
94            handle: Some(handle),
95            stop_signal,
96            commit_due,
97            force_commit,
98            config,
99        }
100    }
101
102    /// Timer loop that runs in the background
103    fn timer_loop(
104        stop_signal: Arc<AtomicBool>,
105        commit_due: Arc<AtomicBool>,
106        force_commit: Arc<AtomicBool>,
107        interval: Duration,
108    ) {
109        let sleep_interval = Duration::from_millis(100);
110        let mut elapsed = Duration::ZERO;
111
112        loop {
113            if stop_signal.load(Ordering::SeqCst) {
114                // Signal final commit on stop
115                commit_due.store(true, Ordering::SeqCst);
116                break;
117            }
118
119            // Check for forced commit
120            if force_commit.swap(false, Ordering::SeqCst) {
121                commit_due.store(true, Ordering::SeqCst);
122                elapsed = Duration::ZERO;
123            }
124
125            // Check if interval elapsed
126            if elapsed >= interval {
127                commit_due.store(true, Ordering::SeqCst);
128                elapsed = Duration::ZERO;
129            }
130
131            thread::sleep(sleep_interval);
132            elapsed += sleep_interval;
133        }
134    }
135
136    /// Check if a commit is due and try to perform it
137    pub fn try_commit(&self, staging: &Staging, repo: &Repository) -> FsResult<Option<String>> {
138        if !self.commit_due.swap(false, Ordering::SeqCst) {
139            return Ok(None);
140        }
141
142        let result = commit_now(staging, repo)?;
143        if result == "no-changes" {
144            Ok(None)
145        } else {
146            Ok(Some(result))
147        }
148    }
149
150    /// Request an immediate forced commit
151    pub fn force_commit(&self) {
152        self.force_commit.store(true, Ordering::SeqCst);
153    }
154
155    /// Check if a commit is currently due
156    pub fn is_commit_due(&self) -> bool {
157        self.commit_due.load(Ordering::SeqCst)
158    }
159
160    /// Stop the timer
161    pub fn stop(&mut self) {
162        self.stop_signal.store(true, Ordering::SeqCst);
163        if let Some(handle) = self.handle.take() {
164            let _ = handle.join();
165        }
166    }
167
168    /// Check if the timer is still running
169    pub fn is_running(&self) -> bool {
170        self.handle
171            .as_ref()
172            .map(|h| !h.is_finished())
173            .unwrap_or(false)
174    }
175}
176
177impl Drop for CommitTimer {
178    fn drop(&mut self) {
179        self.stop();
180    }
181}
182
183/// Legacy CommitWorker type alias for backwards compatibility
184pub type CommitWorker = CommitTimer;
185
186impl CommitWorker {
187    /// Start a new commit worker (delegates to CommitTimer)
188    ///
189    /// Note: The repo parameter is ignored as commits happen synchronously.
190    /// Use `try_commit()` or `commit_now()` to perform actual commits.
191    pub fn start_legacy(
192        _staging: Staging,
193        _repo: Arc<Repository>,
194        config: CommitConfig,
195    ) -> FsResult<Self> {
196        Ok(CommitTimer::start(config))
197    }
198}
199
200/// Perform the actual commit operation
201fn do_commit(state: &mut StagingState, repo: &Repository) -> FsResult<String> {
202    let author = state.author().to_string();
203    let branch = state.branch().to_string();
204
205    // Collect staged changes (new/modified files and deletions)
206    let mut staged_files: Vec<(String, Vec<u8>)> = Vec::new();
207    let mut deletions: std::collections::HashSet<String> = std::collections::HashSet::new();
208
209    for (path, staged_file) in state.files() {
210        if staged_file.is_deleted {
211            deletions.insert(path.clone());
212        } else if staged_file.modified {
213            let content = state.read_file(path)?;
214            staged_files.push((path.clone(), content));
215        }
216    }
217
218    if staged_files.is_empty() && deletions.is_empty() {
219        // Nothing to commit
220        state.mark_clean();
221        return Ok("no-changes".to_string());
222    }
223
224    // Get parent commit hash and inherit files from parent
225    let parent_hash = repo
226        .branches()
227        .get(&branch)
228        .ok()
229        .and_then(|b| b.tip().ok())
230        .map(|c| c.hash);
231
232    // Build complete file list: parent files + staged changes - deletions
233    let mut files_to_commit: Vec<(String, Vec<u8>)> = Vec::new();
234    let staged_paths: std::collections::HashSet<String> =
235        staged_files.iter().map(|(p, _)| p.clone()).collect();
236
237    // First, add files from parent that aren't being modified or deleted
238    if let Some(ref parent) = parent_hash {
239        if let Ok(parent_files) = repo.list_files_internal(parent) {
240            for file_info in parent_files {
241                // Skip if this file is being deleted
242                if deletions.contains(&file_info.name) {
243                    continue;
244                }
245                // Skip if this file is being replaced with staged content
246                if staged_paths.contains(&file_info.name) {
247                    continue;
248                }
249                // Read the file content from parent and include it
250                if let Ok(content) = repo.read_file_internal(parent, &file_info.name) {
251                    files_to_commit.push((file_info.name, content));
252                }
253            }
254        }
255    }
256
257    // Add all staged files (new and modified)
258    files_to_commit.extend(staged_files);
259
260    // Build commit message
261    let deletions_vec: Vec<String> = deletions.iter().cloned().collect();
262    let message = build_commit_message(&files_to_commit, &deletions_vec);
263
264    // Convert files for commit
265    let files_refs: Vec<(&str, &[u8])> = files_to_commit
266        .iter()
267        .map(|(p, c)| (p.as_str(), c.as_slice()))
268        .collect();
269
270    // Perform commit
271    let commit_hash = repo
272        .commit_internal(
273            &files_refs,
274            &message,
275            &author,
276            parent_hash.as_deref(),
277            Some(&branch),
278        )
279        .map_err(|e| FsError::DatabaseError(format!("Commit failed: {}", e)))?;
280
281    // Clear staging after successful commit
282    state.clear()?;
283
284    Ok(commit_hash)
285}
286
287/// Build a commit message from the staged changes
288fn build_commit_message(files: &[(String, Vec<u8>)], deletions: &[String]) -> String {
289    let mut parts = Vec::new();
290
291    if !files.is_empty() {
292        if files.len() == 1 {
293            parts.push(format!("Update {}", files[0].0));
294        } else {
295            parts.push(format!("Update {} files", files.len()));
296        }
297    }
298
299    if !deletions.is_empty() {
300        if deletions.len() == 1 {
301            parts.push(format!("Delete {}", deletions[0]));
302        } else {
303            parts.push(format!("Delete {} files", deletions.len()));
304        }
305    }
306
307    if parts.is_empty() {
308        "Auto-commit".to_string()
309    } else {
310        parts.join(", ")
311    }
312}
313
314/// Synchronous commit helper (for forced commits from main thread)
315pub fn commit_now(staging: &Staging, repo: &Repository) -> FsResult<String> {
316    let mut state = staging.write();
317
318    if !state.is_dirty() {
319        return Ok("no-changes".to_string());
320    }
321
322    do_commit(&mut state, repo)
323}
324
325/// Force a commit and wait for completion
326pub fn force_commit_sync(staging: &Staging, repo: &Repository) -> FsResult<String> {
327    commit_now(staging, repo)
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn test_commit_config_default() {
336        let config = CommitConfig::default();
337        assert_eq!(config.interval, DEFAULT_COMMIT_INTERVAL);
338        assert!(config.commit_on_shutdown);
339    }
340
341    #[test]
342    fn test_build_commit_message() {
343        let files = vec![
344            ("file1.txt".to_string(), vec![1, 2, 3]),
345            ("file2.txt".to_string(), vec![4, 5, 6]),
346        ];
347        let deletions = vec!["old.txt".to_string()];
348
349        let msg = build_commit_message(&files, &deletions);
350        assert!(msg.contains("Update 2 files"));
351        assert!(msg.contains("Delete old.txt"));
352    }
353
354    #[test]
355    fn test_build_commit_message_single_file() {
356        let files = vec![("readme.md".to_string(), vec![1, 2, 3])];
357        let deletions: Vec<String> = vec![];
358
359        let msg = build_commit_message(&files, &deletions);
360        assert_eq!(msg, "Update readme.md");
361    }
362
363    #[test]
364    fn test_commit_timer_creation() {
365        let config = CommitConfig::default();
366        let mut timer = CommitTimer::start(config);
367        assert!(timer.is_running());
368        timer.stop();
369    }
370}