heroforge_core/fs/
commit_thread.rs1use 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
23pub const DEFAULT_COMMIT_INTERVAL: Duration = Duration::from_secs(60);
25
26pub const MIN_COMMIT_INTERVAL: Duration = Duration::from_secs(5);
28
29#[derive(Debug, Clone)]
31pub struct CommitConfig {
32 pub interval: Duration,
34
35 pub lock_timeout: Duration,
37
38 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
52pub struct CommitTimer {
57 handle: Option<JoinHandle<()>>,
59
60 stop_signal: Arc<AtomicBool>,
62
63 commit_due: Arc<AtomicBool>,
65
66 force_commit: Arc<AtomicBool>,
68
69 #[allow(dead_code)]
71 config: CommitConfig,
72}
73
74impl CommitTimer {
75 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 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 commit_due.store(true, Ordering::SeqCst);
116 break;
117 }
118
119 if force_commit.swap(false, Ordering::SeqCst) {
121 commit_due.store(true, Ordering::SeqCst);
122 elapsed = Duration::ZERO;
123 }
124
125 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 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 pub fn force_commit(&self) {
152 self.force_commit.store(true, Ordering::SeqCst);
153 }
154
155 pub fn is_commit_due(&self) -> bool {
157 self.commit_due.load(Ordering::SeqCst)
158 }
159
160 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 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
183pub type CommitWorker = CommitTimer;
185
186impl CommitWorker {
187 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
200fn 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 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 state.mark_clean();
221 return Ok("no-changes".to_string());
222 }
223
224 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 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 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 if deletions.contains(&file_info.name) {
243 continue;
244 }
245 if staged_paths.contains(&file_info.name) {
247 continue;
248 }
249 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 files_to_commit.extend(staged_files);
259
260 let deletions_vec: Vec<String> = deletions.iter().cloned().collect();
262 let message = build_commit_message(&files_to_commit, &deletions_vec);
263
264 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 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 state.clear()?;
283
284 Ok(commit_hash)
285}
286
287fn 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
314pub 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
325pub 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}