1use std::path::{Path, PathBuf};
7
8use tracing::{debug, info, warn};
9
10use crate::{
11 engine::{FetchResult, GitEngine, MergeResult, PushResult},
12 error::GitError
13};
14
15#[derive(Debug)]
17pub struct GitOps {
18 engine: GitEngine
20}
21
22impl GitOps {
23 pub fn new(repo_path: PathBuf, branch: String) -> anyhow::Result<Self> {
29 let engine = GitEngine::new(repo_path, branch)?;
30 Ok(Self { engine })
31 }
32
33 #[must_use]
35 pub const fn engine(&self) -> &GitEngine {
36 &self.engine
37 }
38
39 #[must_use]
41 pub fn repo_path(&self) -> &Path {
42 self.engine.repo_path()
43 }
44
45 pub async fn startup_sync(&self) -> anyhow::Result<StartupSyncResult> {
53 info!("startup sync");
54
55 match self.engine.fetch().await {
56 Ok(FetchResult::UpToDate) => {
57 debug!("startup sync: already up to date");
58 return Ok(StartupSyncResult::UpToDate);
59 }
60 Ok(FetchResult::Updated { commits }) => {
61 debug!(commits, "startup sync: new commits received");
62 }
63 Err(e) => {
64 warn!("startup sync: fetch failed, working offline: {e}");
65 return Ok(StartupSyncResult::Offline);
66 }
67 }
68
69 match self.engine.pull().await {
70 Ok(MergeResult::UpToDate) => Ok(StartupSyncResult::UpToDate),
71 Ok(MergeResult::FastForward) => {
72 info!("startup sync: fast-forward merge");
73 Ok(StartupSyncResult::Updated)
74 }
75 Ok(MergeResult::Merged { commit }) => {
76 info!(commit = %commit, "startup sync: merge commit created");
77 Ok(StartupSyncResult::Merged)
78 }
79 Ok(MergeResult::Conflict { files }) => {
80 warn!(files = ?files, "startup sync: conflicts detected");
81 Ok(StartupSyncResult::Conflicts { files })
82 }
83 Err(e) => {
84 warn!("startup sync: pull failed: {e}");
85 Ok(StartupSyncResult::Offline)
86 }
87 }
88 }
89
90 pub async fn commit_changes(&self, files: &[PathBuf], message: &str) -> anyhow::Result<String> {
96 if files.is_empty() {
97 return Err(GitError::NoFilesToCommit.into());
98 }
99
100 self.engine.stage(files).await?;
101 let hash = self.engine.commit(message).await?;
102 info!(hash = %hash, files = files.len(), "commit created");
103 Ok(hash)
104 }
105
106 pub async fn auto_commit(&self, files: &[PathBuf]) -> anyhow::Result<String> {
112 let message = format!(
113 "[auto] {} file(s) changed at {}",
114 files.len(),
115 chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
116 );
117 self.commit_changes(files, &message).await
118 }
119
120 pub async fn push_with_retry(&self, max_retries: u32) -> anyhow::Result<()> {
126 let mut retries = 0;
127
128 loop {
129 match self.engine.push().await? {
130 PushResult::Success => {
131 info!("push succeeded");
132 return Ok(());
133 }
134 PushResult::NoRemote => {
135 warn!("no remote configured, skipping push");
136 return Ok(());
137 }
138 PushResult::Rejected => {
139 if retries >= max_retries {
140 return Err(GitError::PushRejected { retries: max_retries }.into());
141 }
142
143 retries += 1;
144 warn!(retries, "push rejected, pulling and retrying");
145
146 match self.engine.pull().await? {
147 MergeResult::Conflict { files } => {
148 return Err(GitError::Conflict { files }.into());
149 }
150 _ => {
151 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
152 }
153 }
154 }
155 }
156 }
157 }
158
159 pub async fn check_remote(&self) -> anyhow::Result<bool> {
165 let local_head = self.engine.get_head_commit().await?;
166 self.engine.fetch().await?;
167 let remote_head = self.engine.get_remote_head().await?;
168 Ok(remote_head.is_some_and(|r| r != local_head))
169 }
170}
171
172#[derive(Debug, Clone)]
174pub enum StartupSyncResult {
175 UpToDate,
177 Updated,
179 Merged,
181 Conflicts {
183 files: Vec<PathBuf>
185 },
186 Offline
188}
189
190#[cfg(test)]
191#[allow(clippy::expect_used)]
192mod tests {
193 use super::*;
194 use crate::engine::tests::{create_bare_and_two_clones, create_test_repo};
195
196 const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
198
199 #[tokio::test]
200 async fn test_commit_changes() {
201 eprintln!("[TEST] test_commit_changes");
202 let (_tmp, path) = create_test_repo().await;
203 let ops = GitOps::new(path.clone(), "main".to_string()).expect("ops");
204
205 let file = path.join("new.txt");
206 tokio::fs::write(&file, "data").await.expect("write");
207
208 let hash = ops.commit_changes(&[file], "test commit").await.expect("commit");
209 assert!(!hash.is_empty(), "hash should not be empty");
210 assert_eq!(hash.len(), 40, "SHA-1 hash should be 40 characters");
211 }
212
213 #[tokio::test]
214 async fn test_auto_commit_message() {
215 eprintln!("[TEST] test_auto_commit_message");
216 let (_tmp, path) = create_test_repo().await;
217 let ops = GitOps::new(path.clone(), "main".to_string()).expect("ops");
218
219 let file = path.join("auto.txt");
220 tokio::fs::write(&file, "auto data").await.expect("write");
221
222 let hash = ops.auto_commit(&[file]).await.expect("auto_commit");
223 assert!(!hash.is_empty(), "auto_commit should create a commit");
224
225 let output = tokio::process::Command::new("git")
227 .current_dir(&path)
228 .args(["log", "-1", "--format=%s"])
229 .output()
230 .await
231 .expect("git log");
232 let message = String::from_utf8_lossy(&output.stdout);
233 assert!(message.contains("[auto]"), "message should contain [auto]: {message}");
234 assert!(
235 message.contains("1 file(s) changed"),
236 "message should indicate file count: {message}"
237 );
238 }
239
240 #[tokio::test]
241 async fn test_push_with_retry_bare() {
242 eprintln!("[TEST] test_push_with_retry_bare");
243 tokio::time::timeout(TEST_TIMEOUT, async {
244 let (_tmp, _bare, clone1, _clone2) = create_bare_and_two_clones().await;
245 let ops = GitOps::new(clone1.clone(), "main".to_string()).expect("ops");
246
247 let file = clone1.join("pushed.txt");
248 tokio::fs::write(&file, "push data").await.expect("write");
249 ops.commit_changes(&[file], "push test").await.expect("commit");
250
251 ops.push_with_retry(3).await.expect("push_with_retry");
252 })
253 .await
254 .expect("test timed out — possible deadlock");
255 }
256
257 #[tokio::test]
258 async fn test_push_rejected_retry() {
259 eprintln!("[TEST] test_push_rejected_retry");
260 tokio::time::timeout(TEST_TIMEOUT, async {
261 let (_tmp, _bare, clone1, clone2) = create_bare_and_two_clones().await;
262 let ops1 = GitOps::new(clone1.clone(), "main".to_string()).expect("ops1");
263 let ops2 = GitOps::new(clone2.clone(), "main".to_string()).expect("ops2");
264
265 let file1 = clone1.join("from_clone1.txt");
267 tokio::fs::write(&file1, "data from clone1").await.expect("write");
268 ops1.commit_changes(&[file1], "from clone1").await.expect("commit1");
269 ops1.push_with_retry(1).await.expect("push1");
270
271 let file2 = clone2.join("from_clone2.txt");
273 tokio::fs::write(&file2, "data from clone2").await.expect("write");
274 ops2.commit_changes(&[file2], "from clone2").await.expect("commit2");
275
276 ops2.push_with_retry(3).await.expect("push_with_retry");
278
279 assert!(
281 clone2.join("from_clone1.txt").exists(),
282 "file from clone1 should exist after retry"
283 );
284 })
285 .await
286 .expect("test timed out — possible deadlock");
287 }
288
289 #[tokio::test]
290 async fn test_startup_sync_clean() {
291 eprintln!("[TEST] test_startup_sync_clean");
292 tokio::time::timeout(TEST_TIMEOUT, async {
293 let (_tmp, _bare, clone1, _clone2) = create_bare_and_two_clones().await;
294 let ops = GitOps::new(clone1, "main".to_string()).expect("ops");
295
296 let result = ops.startup_sync().await.expect("startup_sync");
297 assert!(matches!(result, StartupSyncResult::UpToDate), "clean repo: {result:?}");
298 })
299 .await
300 .expect("test timed out — possible deadlock");
301 }
302
303 #[tokio::test]
305 async fn test_commit_changes_stages_and_commits() {
306 eprintln!("[TEST] test_commit_changes_stages_and_commits");
307 let (_tmp, path) = create_test_repo().await;
308 let ops = GitOps::new(path.clone(), "main".to_string()).expect("ops");
309
310 let head_before = tokio::process::Command::new("git")
312 .current_dir(&path)
313 .args(["rev-parse", "HEAD"])
314 .output()
315 .await
316 .expect("rev-parse before");
317 let head_before = String::from_utf8_lossy(&head_before.stdout).trim().to_string();
318
319 let file = path.join("staged_and_committed.txt");
321 tokio::fs::write(&file, "test data").await.expect("write");
322
323 let msg = "test full cycle stage+commit";
325 let hash = ops.commit_changes(&[file], msg).await.expect("commit_changes");
326
327 assert_ne!(hash, head_before, "commit hash should differ from previous HEAD");
329
330 let output = tokio::process::Command::new("git")
332 .current_dir(&path)
333 .args(["log", "-1", "--format=%s"])
334 .output()
335 .await
336 .expect("git log");
337 let message = String::from_utf8_lossy(&output.stdout);
338 assert!(
339 message.contains(msg),
340 "git log should contain commit message: {message}"
341 );
342
343 let diff_output = tokio::process::Command::new("git")
345 .current_dir(&path)
346 .args(["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"])
347 .output()
348 .await
349 .expect("diff-tree");
350 let files_in_commit = String::from_utf8_lossy(&diff_output.stdout);
351 assert!(
352 files_in_commit.contains("staged_and_committed.txt"),
353 "staged_and_committed.txt should be in the commit: {files_in_commit}"
354 );
355 }
356
357 #[tokio::test]
360 async fn test_auto_commit_message_format() {
361 eprintln!("[TEST] test_auto_commit_message_format");
362 let (_tmp, path) = create_test_repo().await;
363 let ops = GitOps::new(path.clone(), "main".to_string()).expect("ops");
364
365 let files: Vec<PathBuf> = (1..=3)
367 .map(|i| {
368 let p = path.join(format!("file_{i}.txt"));
369 std::fs::write(&p, format!("content {i}")).expect("write");
370 p
371 })
372 .collect();
373
374 let hash = ops.auto_commit(&files).await.expect("auto_commit");
375 assert_eq!(hash.len(), 40, "SHA-1 hash should be 40 characters");
376
377 let output = tokio::process::Command::new("git")
379 .current_dir(&path)
380 .args(["log", "-1", "--format=%s"])
381 .output()
382 .await
383 .expect("git log");
384 let message = String::from_utf8_lossy(&output.stdout);
385 let message = message.trim();
386
387 assert!(message.contains("[auto]"), "message should contain '[auto]': {message}");
388 assert!(
389 message.contains("3 file(s) changed"),
390 "message should contain '3 file(s) changed': {message}"
391 );
392 assert!(
394 message.contains(&chrono::Local::now().format("%Y-%m-%d").to_string()),
395 "message should contain current date: {message}"
396 );
397 }
398
399 #[tokio::test]
401 async fn test_git_log_after_commit() {
402 eprintln!("[TEST] test_git_log_after_commit");
403 let (_tmp, path) = create_test_repo().await;
404 let ops = GitOps::new(path.clone(), "main".to_string()).expect("ops");
405
406 let file = path.join("log_test.txt");
407 tokio::fs::write(&file, "log data").await.expect("write");
408
409 let commit_msg = "test message for git log";
410 let hash = ops.commit_changes(&[file], commit_msg).await.expect("commit");
411
412 let output = tokio::process::Command::new("git")
414 .current_dir(&path)
415 .args(["log", "--oneline", "-5"])
416 .output()
417 .await
418 .expect("git log --oneline");
419 let log_output = String::from_utf8_lossy(&output.stdout);
420
421 assert!(
422 log_output.contains(commit_msg),
423 "git log --oneline should contain commit message: {log_output}"
424 );
425 let short_hash = &hash[..7];
427 assert!(
428 log_output.contains(short_hash),
429 "git log --oneline should contain short hash {short_hash}: {log_output}"
430 );
431 }
432
433 #[tokio::test]
435 async fn test_sync_empty_changeset() {
436 eprintln!("[TEST] test_sync_empty_changeset");
437 let (_tmp, path) = create_test_repo().await;
438 let ops = GitOps::new(path, "main".to_string()).expect("ops");
439
440 let result = ops.commit_changes(&[], "empty commit").await;
442 assert!(result.is_err(), "commit with empty file list should return an error");
443 assert!(
444 result.expect_err("err").to_string().contains("no files to commit"),
445 "error should contain 'no files to commit'"
446 );
447 }
448
449 #[tokio::test]
451 async fn test_full_cycle_init_write_commit_push() {
452 eprintln!("[TEST] test_full_cycle_init_write_commit_push");
453 tokio::time::timeout(TEST_TIMEOUT, async {
454 let (_tmp, bare_path, clone1, _clone2) = create_bare_and_two_clones().await;
455 let ops = GitOps::new(clone1.clone(), "main".to_string()).expect("ops");
456
457 let file = clone1.join("cycle_test.txt");
459 tokio::fs::write(&file, "full cycle").await.expect("write");
460
461 let hash = ops
463 .commit_changes(&[file], "full cycle: create file")
464 .await
465 .expect("commit");
466 assert_eq!(hash.len(), 40, "SHA-1 hash should be 40 characters");
467
468 ops.push_with_retry(1).await.expect("push");
470
471 let output = tokio::process::Command::new("git")
473 .current_dir(&bare_path)
474 .args(["log", "--oneline", "-1"])
475 .output()
476 .await
477 .expect("git log in bare");
478 let log_line = String::from_utf8_lossy(&output.stdout);
479 assert!(
480 log_line.contains("full cycle"),
481 "commit should be in bare repo: {log_line}"
482 );
483 })
484 .await
485 .expect("test timed out — possible deadlock");
486 }
487
488 #[tokio::test]
492 async fn test_startup_sync_with_remote() {
493 eprintln!("[TEST] test_startup_sync_with_remote");
494 tokio::time::timeout(TEST_TIMEOUT, async {
495 let (_tmp, bare_path, clone1, _clone2_orig) = create_bare_and_two_clones().await;
496
497 let ops1 = GitOps::new(clone1.clone(), "main".to_string()).expect("ops1");
499 let file = clone1.join("synced_file.txt");
500 tokio::fs::write(&file, "content for synchronization")
501 .await
502 .expect("write");
503 ops1
504 .commit_changes(&[file], "add synced_file.txt")
505 .await
506 .expect("commit");
507 ops1.push_with_retry(1).await.expect("push");
508
509 let clone3_path = _tmp.path().join("clone3");
511 tokio::process::Command::new("git")
512 .args(["clone"])
513 .arg(&bare_path)
514 .arg(&clone3_path)
515 .output()
516 .await
517 .expect("clone3");
518
519 tokio::process::Command::new("git")
521 .current_dir(&clone3_path)
522 .args(["config", "user.email", "test3@test.com"])
523 .output()
524 .await
525 .expect("config email");
526 tokio::process::Command::new("git")
527 .current_dir(&clone3_path)
528 .args(["config", "user.name", "Test3"])
529 .output()
530 .await
531 .expect("config name");
532
533 let ops3 = GitOps::new(clone3_path.clone(), "main".to_string()).expect("ops3");
535 let result = ops3.startup_sync().await.expect("startup_sync");
536 assert!(
537 matches!(
538 result,
539 StartupSyncResult::UpToDate | StartupSyncResult::Updated | StartupSyncResult::Merged
540 ),
541 "startup_sync should be UpToDate/Updated/Merged: {result:?}"
542 );
543
544 assert!(
546 clone3_path.join("synced_file.txt").exists(),
547 "synced_file.txt should be in the new clone after startup_sync"
548 );
549 })
550 .await
551 .expect("test timed out — possible deadlock");
552 }
553
554 #[tokio::test]
557 async fn test_commit_with_multiple_files() {
558 eprintln!("[TEST] test_commit_with_multiple_files");
559 let (_tmp, path) = create_test_repo().await;
560 let ops = GitOps::new(path.clone(), "main".to_string()).expect("ops");
561
562 let before_output = tokio::process::Command::new("git")
564 .current_dir(&path)
565 .args(["rev-list", "--count", "HEAD"])
566 .output()
567 .await
568 .expect("rev-list before");
569 let before_count: usize = String::from_utf8_lossy(&before_output.stdout)
570 .trim()
571 .parse()
572 .expect("parse count");
573
574 let files: Vec<PathBuf> = (1..=3)
576 .map(|i| {
577 let p = path.join(format!("multi_{i}.txt"));
578 std::fs::write(&p, format!("file content {i}")).expect("write");
579 p
580 })
581 .collect();
582
583 let hash = ops
585 .commit_changes(&files, "commit with three files")
586 .await
587 .expect("commit_changes");
588 assert_eq!(hash.len(), 40, "SHA-1 hash should be 40 characters");
589
590 let after_output = tokio::process::Command::new("git")
592 .current_dir(&path)
593 .args(["rev-list", "--count", "HEAD"])
594 .output()
595 .await
596 .expect("rev-list after");
597 let after_count: usize = String::from_utf8_lossy(&after_output.stdout)
598 .trim()
599 .parse()
600 .expect("parse count");
601
602 assert_eq!(
603 after_count - before_count,
604 1,
605 "should be exactly 1 new commit, not {}",
606 after_count - before_count
607 );
608
609 let log_output = tokio::process::Command::new("git")
611 .current_dir(&path)
612 .args(["log", "-1", "--format=%s"])
613 .output()
614 .await
615 .expect("git log");
616 let message = String::from_utf8_lossy(&log_output.stdout);
617 assert!(
618 message.trim().contains("commit with three files"),
619 "commit message should match: {message}"
620 );
621
622 let show_output = tokio::process::Command::new("git")
624 .current_dir(&path)
625 .args(["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"])
626 .output()
627 .await
628 .expect("diff-tree");
629 let changed_files = String::from_utf8_lossy(&show_output.stdout);
630 for i in 1..=3 {
631 assert!(
632 changed_files.contains(&format!("multi_{i}.txt")),
633 "multi_{i}.txt should be in the commit: {changed_files}"
634 );
635 }
636 }
637}