1use std::{
6 path::{Path, PathBuf},
7 sync::Arc
8};
9
10use tokio::sync::RwLock;
11use tracing::{debug, info, warn};
12
13use crate::error::GitError;
14
15#[derive(Debug, Clone)]
17pub enum FetchResult {
18 UpToDate,
20 Updated {
22 commits: usize
24 }
25}
26
27#[derive(Debug, Clone)]
29pub enum PushResult {
30 Success,
32 Rejected,
34 NoRemote
36}
37
38#[derive(Debug, Clone)]
40pub enum MergeResult {
41 UpToDate,
43 FastForward,
45 Merged {
47 commit: String
49 },
50 Conflict {
52 files: Vec<PathBuf>
54 }
55}
56
57#[derive(Debug)]
59pub struct GitEngine {
60 repo_path: PathBuf,
62 branch: String,
64 remote: String,
66 op_lock: Arc<RwLock<()>>
68}
69
70impl GitEngine {
71 pub fn new(repo_path: PathBuf, branch: String) -> anyhow::Result<Self> {
77 let git_dir = repo_path.join(".git");
78 if !git_dir.exists() {
79 return Err(GitError::InvalidRepository { path: repo_path }.into());
80 }
81
82 Ok(Self {
83 repo_path,
84 branch,
85 remote: "origin".to_string(),
86 op_lock: Arc::new(RwLock::new(()))
87 })
88 }
89
90 #[must_use]
92 pub fn repo_path(&self) -> &Path {
93 &self.repo_path
94 }
95
96 #[must_use]
98 pub fn branch(&self) -> &str {
99 &self.branch
100 }
101
102 pub async fn stage(&self, files: &[PathBuf]) -> anyhow::Result<()> {
108 let _lock = self.op_lock.write().await;
109
110 let mut cmd = tokio::process::Command::new("git");
111 cmd.current_dir(&self.repo_path).arg("add");
112
113 for file in files {
114 if let Ok(rel_path) = file.strip_prefix(&self.repo_path) {
115 cmd.arg(rel_path);
116 } else {
117 cmd.arg(file);
118 }
119 }
120
121 let output = cmd.output().await?;
122 if !output.status.success() {
123 let stderr = String::from_utf8_lossy(&output.stderr);
124 return Err(
125 GitError::CommandFailed {
126 op: "git add",
127 stderr: stderr.into_owned()
128 }
129 .into()
130 );
131 }
132
133 debug!(files = files.len(), "staged files");
134 Ok(())
135 }
136
137 pub async fn commit(&self, message: &str) -> anyhow::Result<String> {
143 let _lock = self.op_lock.write().await;
144
145 let output = tokio::process::Command::new("git")
146 .current_dir(&self.repo_path)
147 .args(["commit", "-m", message])
148 .output()
149 .await?;
150
151 if !output.status.success() {
152 let stdout = String::from_utf8_lossy(&output.stdout);
153 let stderr = String::from_utf8_lossy(&output.stderr);
154 if stdout.contains("nothing to commit") || stderr.contains("nothing to commit") {
155 return Err(GitError::NothingToCommit.into());
156 }
157 return Err(
158 GitError::CommandFailed {
159 op: "git commit",
160 stderr: stderr.into_owned()
161 }
162 .into()
163 );
164 }
165
166 let hash = self.get_head_commit().await?;
167 info!(hash = %hash, "commit created");
168 Ok(hash)
169 }
170
171 pub async fn get_head_commit(&self) -> anyhow::Result<String> {
177 let output = tokio::process::Command::new("git")
178 .current_dir(&self.repo_path)
179 .args(["rev-parse", "HEAD"])
180 .output()
181 .await?;
182
183 if !output.status.success() {
184 let stderr = String::from_utf8_lossy(&output.stderr);
185 return Err(
186 GitError::CommandFailed {
187 op: "git rev-parse",
188 stderr: stderr.into_owned()
189 }
190 .into()
191 );
192 }
193
194 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
195 }
196
197 pub async fn get_remote_head(&self) -> anyhow::Result<Option<String>> {
203 let ref_name = format!("{}/{}", self.remote, self.branch);
204
205 let output = tokio::process::Command::new("git")
206 .current_dir(&self.repo_path)
207 .args(["rev-parse", &ref_name])
208 .output()
209 .await?;
210
211 if !output.status.success() {
212 return Ok(None);
213 }
214
215 Ok(Some(String::from_utf8_lossy(&output.stdout).trim().to_string()))
216 }
217
218 pub async fn fetch(&self) -> anyhow::Result<FetchResult> {
224 let _lock = self.op_lock.write().await;
225
226 let before = self.get_remote_head().await?;
227
228 let output = tokio::process::Command::new("git")
229 .current_dir(&self.repo_path)
230 .args(["fetch", &self.remote, &self.branch])
231 .output()
232 .await?;
233
234 if !output.status.success() {
235 let stderr = String::from_utf8_lossy(&output.stderr);
236 if stderr.contains("Could not resolve host") || stderr.contains("Connection refused") {
237 warn!("fetch failed (network): {}", stderr.trim());
238 return Err(
239 GitError::NetworkUnavailable {
240 message: stderr.trim().to_string()
241 }
242 .into()
243 );
244 }
245 return Err(
246 GitError::CommandFailed {
247 op: "git fetch",
248 stderr: stderr.into_owned()
249 }
250 .into()
251 );
252 }
253
254 let after = self.get_remote_head().await?;
255
256 if before == after {
257 debug!("fetch: up to date");
258 Ok(FetchResult::UpToDate)
259 } else {
260 info!("fetch: new commits received");
261 Ok(FetchResult::Updated { commits: 1 })
262 }
263 }
264
265 pub async fn pull(&self) -> anyhow::Result<MergeResult> {
271 let _lock = self.op_lock.write().await;
272
273 let output = tokio::process::Command::new("git")
274 .current_dir(&self.repo_path)
275 .args(["pull", "--rebase", &self.remote, &self.branch])
276 .output()
277 .await?;
278
279 let stdout = String::from_utf8_lossy(&output.stdout);
280 let stderr = String::from_utf8_lossy(&output.stderr);
281
282 if !output.status.success() {
283 if stderr.contains("CONFLICT")
284 || stderr.contains("Automatic merge failed")
285 || stdout.contains("CONFLICT")
286 || stderr.contains("could not apply")
287 {
288 let _ = tokio::process::Command::new("git")
290 .current_dir(&self.repo_path)
291 .args(["rebase", "--abort"])
292 .output()
293 .await;
294
295 let conflict_files = self.get_conflict_files().await.unwrap_or_default();
296 warn!(files = ?conflict_files, "pull/rebase: conflicts detected");
297 return Ok(MergeResult::Conflict { files: conflict_files });
298 }
299 return Err(
300 GitError::CommandFailed {
301 op: "git pull",
302 stderr: stderr.into_owned()
303 }
304 .into()
305 );
306 }
307
308 if stdout.contains("Already up to date") || (stdout.contains("Current branch") && stdout.contains("is up to date"))
309 {
310 debug!("pull: already up to date");
311 Ok(MergeResult::UpToDate)
312 } else if stdout.contains("Fast-forward") {
313 info!("pull: fast-forward");
314 Ok(MergeResult::FastForward)
315 } else {
316 let hash = self.get_head_commit().await?;
317 info!(hash = %hash, "pull: rebased/merged");
318 Ok(MergeResult::Merged { commit: hash })
319 }
320 }
321
322 pub async fn push(&self) -> anyhow::Result<PushResult> {
328 let _lock = self.op_lock.write().await;
329
330 let output = tokio::process::Command::new("git")
331 .current_dir(&self.repo_path)
332 .args(["push", &self.remote, &self.branch])
333 .output()
334 .await?;
335
336 let stderr = String::from_utf8_lossy(&output.stderr);
337
338 if !output.status.success() {
339 if stderr.contains("rejected") || stderr.contains("non-fast-forward") {
340 warn!("push rejected: pull required");
341 return Ok(PushResult::Rejected);
342 }
343 if stderr.contains("No configured push destination") {
344 return Ok(PushResult::NoRemote);
345 }
346 return Err(
347 GitError::CommandFailed {
348 op: "git push",
349 stderr: stderr.into_owned()
350 }
351 .into()
352 );
353 }
354
355 info!("push: success");
356 Ok(PushResult::Success)
357 }
358
359 async fn get_conflict_files(&self) -> anyhow::Result<Vec<PathBuf>> {
361 let output = tokio::process::Command::new("git")
362 .current_dir(&self.repo_path)
363 .args(["diff", "--name-only", "--diff-filter=U"])
364 .output()
365 .await?;
366
367 if !output.status.success() {
368 return Ok(Vec::new());
369 }
370
371 let files = String::from_utf8_lossy(&output.stdout)
372 .lines()
373 .map(|l| self.repo_path.join(l))
374 .collect();
375
376 Ok(files)
377 }
378
379 pub async fn has_changes(&self) -> anyhow::Result<bool> {
385 let output = tokio::process::Command::new("git")
386 .current_dir(&self.repo_path)
387 .args(["status", "--porcelain"])
388 .output()
389 .await?;
390
391 if !output.status.success() {
392 let stderr = String::from_utf8_lossy(&output.stderr);
393 return Err(
394 GitError::CommandFailed {
395 op: "git status",
396 stderr: stderr.into_owned()
397 }
398 .into()
399 );
400 }
401
402 Ok(!output.stdout.is_empty())
403 }
404
405 pub async fn modified_files(&self) -> anyhow::Result<Vec<PathBuf>> {
411 let output = tokio::process::Command::new("git")
412 .current_dir(&self.repo_path)
413 .args(["status", "--porcelain"])
414 .output()
415 .await?;
416
417 if !output.status.success() {
418 let stderr = String::from_utf8_lossy(&output.stderr);
419 return Err(
420 GitError::CommandFailed {
421 op: "git status",
422 stderr: stderr.into_owned()
423 }
424 .into()
425 );
426 }
427
428 let files = String::from_utf8_lossy(&output.stdout)
429 .lines()
430 .filter_map(|line| {
431 if line.len() > 3 {
432 Some(self.repo_path.join(&line[3..]))
433 } else {
434 None
435 }
436 })
437 .collect();
438
439 Ok(files)
440 }
441}
442
443#[cfg(test)]
444#[allow(clippy::expect_used)]
445pub(crate) mod tests {
446 use std::path::PathBuf;
447
448 use super::*;
449
450 const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
452
453 pub(crate) async fn create_test_repo() -> (tempfile::TempDir, PathBuf) {
455 let tmp = tempfile::tempdir().expect("tempdir");
456 let path = tmp.path().to_path_buf();
457
458 tokio::process::Command::new("git")
460 .current_dir(&path)
461 .args(["init", "-b", "main"])
462 .output()
463 .await
464 .expect("git init");
465
466 tokio::process::Command::new("git")
468 .current_dir(&path)
469 .args(["config", "user.email", "test@test.com"])
470 .output()
471 .await
472 .expect("git config email");
473
474 tokio::process::Command::new("git")
475 .current_dir(&path)
476 .args(["config", "user.name", "Test"])
477 .output()
478 .await
479 .expect("git config name");
480
481 tokio::fs::write(path.join("README.md"), "# Test").await.expect("write");
483 tokio::process::Command::new("git")
484 .current_dir(&path)
485 .args(["add", "."])
486 .output()
487 .await
488 .expect("git add");
489 tokio::process::Command::new("git")
490 .current_dir(&path)
491 .args(["commit", "-m", "initial"])
492 .output()
493 .await
494 .expect("git commit");
495
496 (tmp, path)
497 }
498
499 pub(crate) async fn create_bare_and_two_clones() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
501 let tmp = tempfile::tempdir().expect("tempdir");
502 let base = tmp.path().to_path_buf();
503
504 let bare_path = base.join("bare.git");
505 let clone1_path = base.join("clone1");
506 let clone2_path = base.join("clone2");
507
508 tokio::process::Command::new("git")
510 .args(["init", "--bare", "-b", "main"])
511 .arg(&bare_path)
512 .output()
513 .await
514 .expect("git init --bare");
515
516 tokio::process::Command::new("git")
518 .args(["clone"])
519 .arg(&bare_path)
520 .arg(&clone1_path)
521 .output()
522 .await
523 .expect("clone 1");
524
525 for clone_path in [&clone1_path, &clone2_path] {
527 if clone_path == &clone1_path {
528 tokio::process::Command::new("git")
530 .current_dir(&clone1_path)
531 .args(["config", "user.email", "test@test.com"])
532 .output()
533 .await
534 .expect("config");
535 tokio::process::Command::new("git")
536 .current_dir(&clone1_path)
537 .args(["config", "user.name", "Test"])
538 .output()
539 .await
540 .expect("config");
541
542 tokio::fs::write(clone1_path.join("README.md"), "# Shared")
543 .await
544 .expect("write");
545 tokio::process::Command::new("git")
546 .current_dir(&clone1_path)
547 .args(["add", "."])
548 .output()
549 .await
550 .expect("add");
551 tokio::process::Command::new("git")
552 .current_dir(&clone1_path)
553 .args(["commit", "-m", "initial"])
554 .output()
555 .await
556 .expect("commit");
557 tokio::process::Command::new("git")
558 .current_dir(&clone1_path)
559 .args(["push", "-u", "origin", "main"])
560 .output()
561 .await
562 .expect("push");
563 }
564 }
565
566 tokio::process::Command::new("git")
568 .args(["clone"])
569 .arg(&bare_path)
570 .arg(&clone2_path)
571 .output()
572 .await
573 .expect("clone 2");
574
575 tokio::process::Command::new("git")
577 .current_dir(&clone2_path)
578 .args(["config", "user.email", "test2@test.com"])
579 .output()
580 .await
581 .expect("config");
582 tokio::process::Command::new("git")
583 .current_dir(&clone2_path)
584 .args(["config", "user.name", "Test2"])
585 .output()
586 .await
587 .expect("config");
588
589 (tmp, bare_path, clone1_path, clone2_path)
590 }
591
592 #[tokio::test]
593 async fn test_new_valid_repo() {
594 eprintln!("[TEST] test_new_valid_repo");
595 let (_tmp, path) = create_test_repo().await;
596 let engine = GitEngine::new(path.clone(), "main".to_string());
597 assert!(engine.is_ok(), "should open a valid repo");
598 assert_eq!(engine.expect("engine").repo_path(), &path);
599 }
600
601 #[tokio::test]
602 async fn test_new_invalid_path() {
603 eprintln!("[TEST] test_new_invalid_path");
604 let tmp = tempfile::tempdir().expect("tempdir");
605 let result = GitEngine::new(tmp.path().to_path_buf(), "main".to_string());
606 assert!(result.is_err(), "non-git directory should return an error");
607 }
608
609 #[tokio::test]
610 async fn test_stage_files() {
611 eprintln!("[TEST] test_stage_files");
612 let (_tmp, path) = create_test_repo().await;
613 let engine = GitEngine::new(path.clone(), "main".to_string()).expect("engine");
614
615 let new_file = path.join("added.txt");
617 tokio::fs::write(&new_file, "new content").await.expect("write");
618
619 engine.stage(&[new_file]).await.expect("stage");
620
621 let output = tokio::process::Command::new("git")
623 .current_dir(&path)
624 .args(["status", "--porcelain"])
625 .output()
626 .await
627 .expect("status");
628 let status = String::from_utf8_lossy(&output.stdout);
629 assert!(status.contains("A added.txt"), "file should be staged: {status}");
630 }
631
632 #[tokio::test]
633 async fn test_commit_creates_commit() {
634 eprintln!("[TEST] test_commit_creates_commit");
635 let (_tmp, path) = create_test_repo().await;
636 let engine = GitEngine::new(path.clone(), "main".to_string()).expect("engine");
637
638 let before = engine.get_head_commit().await.expect("head");
639
640 tokio::fs::write(path.join("new.txt"), "data").await.expect("write");
641 engine.stage(&[path.join("new.txt")]).await.expect("stage");
642 let hash = engine.commit("test commit").await.expect("commit");
643
644 assert_ne!(hash, before, "commit hash should change");
645 assert!(!hash.is_empty(), "hash should not be empty");
646 }
647
648 #[tokio::test]
649 async fn test_commit_empty_errors() {
650 eprintln!("[TEST] test_commit_empty_errors");
651 let (_tmp, path) = create_test_repo().await;
652 let engine = GitEngine::new(path, "main".to_string()).expect("engine");
653
654 let result = engine.commit("empty").await;
655 assert!(result.is_err(), "commit without changes should return an error");
656 }
657
658 #[tokio::test]
659 async fn test_get_head_commit() {
660 eprintln!("[TEST] test_get_head_commit");
661 let (_tmp, path) = create_test_repo().await;
662 let engine = GitEngine::new(path, "main".to_string()).expect("engine");
663
664 let hash = engine.get_head_commit().await.expect("head");
665 assert_eq!(hash.len(), 40, "SHA-1 hash should be 40 characters");
666 }
667
668 #[tokio::test]
669 async fn test_has_changes_new_file() {
670 eprintln!("[TEST] test_has_changes_new_file");
671 let (_tmp, path) = create_test_repo().await;
672 let engine = GitEngine::new(path.clone(), "main".to_string()).expect("engine");
673
674 tokio::fs::write(path.join("untracked.txt"), "data")
675 .await
676 .expect("write");
677 assert!(
678 engine.has_changes().await.expect("has_changes"),
679 "new file = has changes"
680 );
681 }
682
683 #[tokio::test]
684 async fn test_has_changes_clean() {
685 eprintln!("[TEST] test_has_changes_clean");
686 let (_tmp, path) = create_test_repo().await;
687 let engine = GitEngine::new(path, "main".to_string()).expect("engine");
688
689 assert!(
690 !engine.has_changes().await.expect("has_changes"),
691 "clean repo = no changes"
692 );
693 }
694
695 #[tokio::test]
696 async fn test_modified_files() {
697 eprintln!("[TEST] test_modified_files");
698 let (_tmp, path) = create_test_repo().await;
699 let engine = GitEngine::new(path.clone(), "main".to_string()).expect("engine");
700
701 tokio::fs::write(path.join("mod.txt"), "modified").await.expect("write");
702
703 let files = engine.modified_files().await.expect("modified_files");
704 assert!(!files.is_empty(), "should have modified files");
705 }
706
707 #[tokio::test]
708 async fn test_push_no_remote() {
709 eprintln!("[TEST] test_push_no_remote");
710 let (_tmp, path) = create_test_repo().await;
711 let engine = GitEngine::new(path, "main".to_string()).expect("engine");
712
713 let result = engine.push().await;
715 assert!(result.is_err(), "push without remote should return an error");
716 }
717
718 #[tokio::test]
719 async fn test_push_to_bare() {
720 eprintln!("[TEST] test_push_to_bare");
721 tokio::time::timeout(TEST_TIMEOUT, async {
722 let (_tmp, _bare, clone1, _clone2) = create_bare_and_two_clones().await;
723 let engine = GitEngine::new(clone1.clone(), "main".to_string()).expect("engine");
724
725 tokio::fs::write(clone1.join("new.txt"), "data").await.expect("write");
727 engine.stage(&[clone1.join("new.txt")]).await.expect("stage");
728 engine.commit("new file").await.expect("commit");
729
730 let result = engine.push().await.expect("push");
731 assert!(matches!(result, PushResult::Success), "push to bare: {result:?}");
732 })
733 .await
734 .expect("test timed out — possible deadlock");
735 }
736
737 #[tokio::test]
738 async fn test_pull_fast_forward() {
739 eprintln!("[TEST] test_pull_fast_forward");
740 tokio::time::timeout(TEST_TIMEOUT, async {
741 let (_tmp, _bare, clone1, clone2) = create_bare_and_two_clones().await;
742
743 tokio::fs::write(clone1.join("from_clone1.txt"), "data")
745 .await
746 .expect("write");
747 let engine1 = GitEngine::new(clone1.clone(), "main".to_string()).expect("engine1");
748 engine1.stage(&[clone1.join("from_clone1.txt")]).await.expect("stage");
749 engine1.commit("from clone1").await.expect("commit");
750 engine1.push().await.expect("push");
751
752 let engine2 = GitEngine::new(clone2.clone(), "main".to_string()).expect("engine2");
754 let result = engine2.pull().await.expect("pull");
755 assert!(
756 matches!(result, MergeResult::FastForward),
757 "pull should be fast-forward: {result:?}"
758 );
759
760 assert!(clone2.join("from_clone1.txt").exists(), "file should appear after pull");
762 })
763 .await
764 .expect("test timed out — possible deadlock");
765 }
766
767 #[tokio::test]
769 async fn test_merge_different_lines_auto_merges() {
770 eprintln!("[TEST] test_merge_different_lines_auto_merges");
771 tokio::time::timeout(TEST_TIMEOUT, async {
772 let (_tmp, _bare, clone1, clone2) = create_bare_and_two_clones().await;
773 let engine1 = GitEngine::new(clone1.clone(), "main".to_string()).expect("engine1");
774 let engine2 = GitEngine::new(clone2.clone(), "main".to_string()).expect("engine2");
775
776 let shared = clone1.join("shared.txt");
778 tokio::fs::write(&shared, "line1\nline2\nline3\n")
779 .await
780 .expect("write shared");
781 engine1.stage(&[shared.clone()]).await.expect("stage");
782 engine1.commit("add shared.txt").await.expect("commit");
783 engine1.push().await.expect("push");
784
785 engine2.pull().await.expect("pull sync");
787
788 tokio::fs::write(&shared, "MODIFIED1\nline2\nline3\n")
790 .await
791 .expect("write clone1");
792 engine1.stage(&[shared]).await.expect("stage clone1");
793 engine1.commit("modify line 1").await.expect("commit clone1");
794 engine1.push().await.expect("push clone1");
795
796 let shared2 = clone2.join("shared.txt");
798 tokio::fs::write(&shared2, "line1\nline2\nMODIFIED3\n")
799 .await
800 .expect("write clone2");
801 engine2.stage(&[shared2.clone()]).await.expect("stage clone2");
802 engine2.commit("modify line 3").await.expect("commit clone2");
803
804 let result = engine2.pull().await.expect("pull clone2");
806 assert!(
807 !matches!(result, MergeResult::Conflict { .. }),
808 "different lines should not conflict: {result:?}"
809 );
810
811 let content = tokio::fs::read_to_string(&shared2).await.expect("read merged");
813 assert!(content.contains("MODIFIED1"), "clone1 edit should be present");
814 assert!(content.contains("MODIFIED3"), "clone2 edit should be present");
815 })
816 .await
817 .expect("test timed out — possible deadlock");
818 }
819
820 #[tokio::test]
822 async fn test_merge_same_line_conflicts() {
823 eprintln!("[TEST] test_merge_same_line_conflicts");
824 tokio::time::timeout(TEST_TIMEOUT, async {
825 let (_tmp, _bare, clone1, clone2) = create_bare_and_two_clones().await;
826 let engine1 = GitEngine::new(clone1.clone(), "main".to_string()).expect("engine1");
827 let engine2 = GitEngine::new(clone2.clone(), "main".to_string()).expect("engine2");
828
829 let shared = clone1.join("shared.txt");
831 tokio::fs::write(&shared, "line1\nline2\nline3\n")
832 .await
833 .expect("write shared");
834 engine1.stage(&[shared.clone()]).await.expect("stage");
835 engine1.commit("add shared.txt").await.expect("commit");
836 engine1.push().await.expect("push");
837
838 engine2.pull().await.expect("pull sync");
840
841 tokio::fs::write(&shared, "CHANGE_FROM_CLONE1\nline2\nline3\n")
843 .await
844 .expect("write clone1");
845 engine1.stage(&[shared]).await.expect("stage clone1");
846 engine1.commit("clone1: modify line 1").await.expect("commit clone1");
847 engine1.push().await.expect("push clone1");
848
849 let shared2 = clone2.join("shared.txt");
851 tokio::fs::write(&shared2, "CHANGE_FROM_CLONE2\nline2\nline3\n")
852 .await
853 .expect("write clone2");
854 engine2.stage(&[shared2]).await.expect("stage clone2");
855 engine2.commit("clone2: modify line 1").await.expect("commit clone2");
856
857 let result = engine2.pull().await.expect("pull clone2");
859 assert!(
860 matches!(result, MergeResult::Conflict { .. }),
861 "same line should conflict: {result:?}"
862 );
863 })
864 .await
865 .expect("test timed out — possible deadlock");
866 }
867
868 #[tokio::test]
870 async fn test_fetch_up_to_date() {
871 eprintln!("[TEST] test_fetch_up_to_date");
872 tokio::time::timeout(TEST_TIMEOUT, async {
873 let (_tmp, _bare, clone1, _clone2) = create_bare_and_two_clones().await;
874 let engine = GitEngine::new(clone1, "main".to_string()).expect("engine");
875
876 let result = engine.fetch().await.expect("fetch");
878 assert!(
879 matches!(result, FetchResult::UpToDate),
880 "fetch without new commits should return UpToDate: {result:?}"
881 );
882 })
883 .await
884 .expect("test timed out — possible deadlock");
885 }
886
887 #[tokio::test]
889 async fn test_delete_vs_edit_conflict() {
890 eprintln!("[TEST] test_delete_vs_edit_conflict");
891 tokio::time::timeout(TEST_TIMEOUT, async {
892 let (_tmp, _bare, clone1, clone2) = create_bare_and_two_clones().await;
893
894 let engine1 = GitEngine::new(clone1.clone(), "main".to_string()).expect("engine1");
895 let engine2 = GitEngine::new(clone2.clone(), "main".to_string()).expect("engine2");
896
897 tokio::fs::write(clone1.join("file.txt"), "original content")
899 .await
900 .expect("write");
901 engine1.stage(&[clone1.join("file.txt")]).await.expect("stage");
902 engine1.commit("add file.txt").await.expect("commit");
903 engine1.push().await.expect("push setup");
904
905 engine2.pull().await.expect("pull sync");
907
908 tokio::fs::remove_file(clone1.join("file.txt")).await.expect("remove");
910 engine1.stage(&[clone1.join("file.txt")]).await.expect("stage");
911 engine1.commit("delete file").await.expect("commit");
912 let push_result = engine1.push().await.expect("push1");
913 assert!(matches!(push_result, PushResult::Success), "push1: {push_result:?}");
914
915 tokio::fs::write(clone2.join("file.txt"), "edited content")
917 .await
918 .expect("write");
919 engine2.stage(&[clone2.join("file.txt")]).await.expect("stage");
920 engine2.commit("edit file").await.expect("commit");
921
922 let push_result = engine2.push().await.expect("push2");
924 assert!(
925 matches!(push_result, PushResult::Rejected),
926 "push2 should be Rejected: {push_result:?}"
927 );
928
929 let merge_result = engine2.pull().await.expect("pull");
931 assert!(
932 matches!(merge_result, MergeResult::Conflict { .. }),
933 "delete vs edit should produce a conflict: {merge_result:?}"
934 );
935 })
936 .await
937 .expect("test timed out — possible deadlock");
938 }
939
940 #[tokio::test]
942 async fn test_modified_files_after_edit() {
943 eprintln!("[TEST] test_modified_files_after_edit");
944 let (_tmp, repo_path) = create_test_repo().await;
945 let engine = GitEngine::new(repo_path.clone(), "main".to_string()).expect("engine");
946
947 tokio::fs::write(repo_path.join("README.md"), "changed content")
949 .await
950 .expect("write");
951
952 let modified = engine.modified_files().await.expect("modified_files");
953 assert!(
954 modified.iter().any(|f| f.to_string_lossy().contains("README.md")),
955 "README.md should be in modified: {modified:?}"
956 );
957 }
958
959 #[tokio::test]
961 async fn test_commit_empty_index_errors() {
962 eprintln!("[TEST] test_commit_empty_index_errors");
963 let (_tmp, path) = create_test_repo().await;
964 let engine = GitEngine::new(path, "main".to_string()).expect("engine");
965
966 let result = engine.commit("empty commit").await;
968 assert!(result.is_err(), "commit without staged files should return an error");
969 let err_msg = result.expect_err("err").to_string();
970 assert!(
971 err_msg.contains("nothing to commit") || err_msg.contains("git commit failed"),
972 "error should contain 'nothing to commit' or 'git commit failed': {err_msg}"
973 );
974 }
975
976 #[tokio::test]
978 async fn test_modified_files_list() {
979 eprintln!("[TEST] test_modified_files_list");
980 let (_tmp, path) = create_test_repo().await;
981 let engine = GitEngine::new(path.clone(), "main".to_string()).expect("engine");
982
983 let file = path.join("tracked.txt");
985 tokio::fs::write(&file, "original").await.expect("write");
986 engine.stage(&[file.clone()]).await.expect("stage");
987 engine.commit("add tracked.txt").await.expect("commit");
988
989 tokio::fs::write(&file, "modified content").await.expect("write mod");
991
992 let files = engine.modified_files().await.expect("modified_files");
993 assert!(
994 files.iter().any(|f| f.to_string_lossy().contains("tracked.txt")),
995 "tracked.txt should be in modified_files: {files:?}"
996 );
997 }
998
999 #[tokio::test]
1001 async fn test_get_head_commit_returns_sha() {
1002 eprintln!("[TEST] test_get_head_commit_returns_sha");
1003 let (_tmp, path) = create_test_repo().await;
1004 let engine = GitEngine::new(path, "main".to_string()).expect("engine");
1005
1006 let sha = engine.get_head_commit().await.expect("head");
1007 assert_eq!(sha.len(), 40, "SHA-1 should be 40 characters, got: {}", sha.len());
1008 assert!(
1010 sha.chars().all(|c| c.is_ascii_hexdigit()),
1011 "SHA-1 should contain only hex characters: {sha}"
1012 );
1013 }
1014
1015 #[tokio::test]
1018 async fn test_edit_vs_delete_conflict() {
1019 eprintln!("[TEST] test_edit_vs_delete_conflict");
1020 tokio::time::timeout(TEST_TIMEOUT, async {
1021 let (_tmp, _bare, clone1, clone2) = create_bare_and_two_clones().await;
1022
1023 let engine1 = GitEngine::new(clone1.clone(), "main".to_string()).expect("engine1");
1024 let engine2 = GitEngine::new(clone2.clone(), "main".to_string()).expect("engine2");
1025
1026 tokio::fs::write(clone1.join("shared.txt"), "original content")
1028 .await
1029 .expect("write");
1030 engine1.stage(&[clone1.join("shared.txt")]).await.expect("stage");
1031 engine1.commit("add shared.txt").await.expect("commit");
1032 engine1.push().await.expect("push setup");
1033
1034 engine2.pull().await.expect("pull sync");
1036
1037 tokio::fs::write(clone1.join("shared.txt"), "edited by clone1")
1039 .await
1040 .expect("write edit");
1041 engine1.stage(&[clone1.join("shared.txt")]).await.expect("stage edit");
1042 engine1.commit("clone1: edit shared.txt").await.expect("commit edit");
1043 let push_result = engine1.push().await.expect("push1");
1044 assert!(matches!(push_result, PushResult::Success), "push1: {push_result:?}");
1045
1046 tokio::fs::remove_file(clone2.join("shared.txt")).await.expect("remove");
1048 engine2.stage(&[clone2.join("shared.txt")]).await.expect("stage delete");
1049 engine2
1050 .commit("clone2: delete shared.txt")
1051 .await
1052 .expect("commit delete");
1053
1054 let push_result = engine2.push().await.expect("push2");
1056 assert!(
1057 matches!(push_result, PushResult::Rejected),
1058 "push2 should be Rejected: {push_result:?}"
1059 );
1060
1061 let merge_result = engine2.pull().await.expect("pull");
1063 assert!(
1064 matches!(merge_result, MergeResult::Conflict { .. }),
1065 "edit vs delete should produce a conflict: {merge_result:?}"
1066 );
1067 })
1068 .await
1069 .expect("test timed out — possible deadlock");
1070 }
1071
1072 #[tokio::test]
1075 async fn test_push_retry_with_local_rebase() {
1076 eprintln!("[TEST] test_push_retry_with_local_rebase");
1077 tokio::time::timeout(TEST_TIMEOUT, async {
1078 let (_tmp, _bare, clone1, clone2) = create_bare_and_two_clones().await;
1079
1080 let engine1 = GitEngine::new(clone1.clone(), "main".to_string()).expect("engine1");
1081 let engine2 = GitEngine::new(clone2.clone(), "main".to_string()).expect("engine2");
1082
1083 tokio::fs::write(clone1.join("file_a.txt"), "data from clone1")
1085 .await
1086 .expect("write a");
1087 engine1.stage(&[clone1.join("file_a.txt")]).await.expect("stage a");
1088 engine1.commit("clone1: add file_a.txt").await.expect("commit a");
1089 let push1 = engine1.push().await.expect("push1");
1090 assert!(matches!(push1, PushResult::Success), "first push: {push1:?}");
1091
1092 tokio::fs::write(clone2.join("file_b.txt"), "data from clone2")
1094 .await
1095 .expect("write b");
1096 engine2.stage(&[clone2.join("file_b.txt")]).await.expect("stage b");
1097 engine2.commit("clone2: add file_b.txt").await.expect("commit b");
1098
1099 let push2 = engine2.push().await.expect("push2");
1101 assert!(
1102 matches!(push2, PushResult::Rejected),
1103 "push from clone2 should be Rejected: {push2:?}"
1104 );
1105
1106 let merge = engine2.pull().await.expect("pull");
1108 assert!(
1109 !matches!(merge, MergeResult::Conflict { .. }),
1110 "different files should not conflict: {merge:?}"
1111 );
1112
1113 let push_retry = engine2.push().await.expect("push retry");
1115 assert!(
1116 matches!(push_retry, PushResult::Success),
1117 "retry push after rebase should be Success: {push_retry:?}"
1118 );
1119
1120 assert!(
1122 clone2.join("file_a.txt").exists(),
1123 "file_a.txt should appear after pull"
1124 );
1125 assert!(clone2.join("file_b.txt").exists(), "file_b.txt should remain");
1126 })
1127 .await
1128 .expect("test timed out — possible deadlock");
1129 }
1130
1131 #[tokio::test]
1133 async fn test_git_status_after_commit() {
1134 eprintln!("[TEST] test_git_status_after_commit");
1135 let (_tmp, repo_path) = create_test_repo().await;
1136 let engine = GitEngine::new(repo_path.clone(), "main".to_string()).expect("engine");
1137
1138 tokio::fs::write(repo_path.join("new.txt"), "new").await.expect("write");
1140 assert!(engine.has_changes().await.expect("has_changes"), "should have changes");
1141
1142 engine.stage(&[repo_path.join("new.txt")]).await.expect("stage");
1144 engine.commit("add new.txt").await.expect("commit");
1145
1146 assert!(
1147 !engine.has_changes().await.expect("has_changes"),
1148 "no changes after commit"
1149 );
1150 }
1151
1152 #[tokio::test]
1156 async fn test_two_repos_edit_different_lines_auto_merge() {
1157 eprintln!("[TEST] test_two_repos_edit_different_lines_auto_merge");
1158 tokio::time::timeout(TEST_TIMEOUT, async {
1159 let (_tmp, _bare, clone1, clone2) = create_bare_and_two_clones().await;
1160 let engine1 = GitEngine::new(clone1.clone(), "main".to_string()).expect("engine1");
1161 let engine2 = GitEngine::new(clone2.clone(), "main".to_string()).expect("engine2");
1162
1163 let file1 = clone1.join("data.txt");
1165 tokio::fs::write(&file1, "line1\nline2\nline3\n")
1166 .await
1167 .expect("write base");
1168 engine1.stage(&[file1.clone()]).await.expect("stage base");
1169 engine1.commit("base file with three lines").await.expect("commit base");
1170 engine1.push().await.expect("push base");
1171
1172 engine2.pull().await.expect("pull sync");
1174
1175 tokio::fs::write(&file1, "modified1\nline2\nline3\n")
1177 .await
1178 .expect("write clone1");
1179 engine1.stage(&[file1]).await.expect("stage clone1");
1180 engine1.commit("clone1: modify line 1").await.expect("commit clone1");
1181 engine1.push().await.expect("push clone1");
1182
1183 let file2 = clone2.join("data.txt");
1185 tokio::fs::write(&file2, "line1\nline2\nmodified3\n")
1186 .await
1187 .expect("write clone2");
1188 engine2.stage(&[file2.clone()]).await.expect("stage clone2");
1189 engine2.commit("clone2: modify line 3").await.expect("commit clone2");
1190
1191 let push_result = engine2.push().await.expect("push clone2");
1193 assert!(
1194 matches!(push_result, PushResult::Rejected),
1195 "push clone2 should be Rejected: {push_result:?}"
1196 );
1197
1198 let merge_result = engine2.pull().await.expect("pull clone2");
1200 assert!(
1201 !matches!(merge_result, MergeResult::Conflict { .. }),
1202 "different lines should not conflict: {merge_result:?}"
1203 );
1204
1205 let content = tokio::fs::read_to_string(&file2).await.expect("read merged");
1207 assert!(content.contains("modified1"), "clone1 edit (line 1) should be present");
1208 assert!(content.contains("line2"), "line 2 should be unchanged");
1209 assert!(content.contains("modified3"), "clone2 edit (line 3) should be present");
1210 })
1211 .await
1212 .expect("test timed out — possible deadlock");
1213 }
1214
1215 #[tokio::test]
1218 async fn test_two_repos_edit_same_line_conflict() {
1219 eprintln!("[TEST] test_two_repos_edit_same_line_conflict");
1220 tokio::time::timeout(TEST_TIMEOUT, async {
1221 let (_tmp, _bare, clone1, clone2) = create_bare_and_two_clones().await;
1222 let engine1 = GitEngine::new(clone1.clone(), "main".to_string()).expect("engine1");
1223 let engine2 = GitEngine::new(clone2.clone(), "main".to_string()).expect("engine2");
1224
1225 let file1 = clone1.join("data.txt");
1227 tokio::fs::write(&file1, "line1\nline2\nline3\n")
1228 .await
1229 .expect("write base");
1230 engine1.stage(&[file1.clone()]).await.expect("stage base");
1231 engine1.commit("base file with three lines").await.expect("commit base");
1232 engine1.push().await.expect("push base");
1233
1234 engine2.pull().await.expect("pull sync");
1236
1237 tokio::fs::write(&file1, "line1\nchanged_by_clone1\nline3\n")
1239 .await
1240 .expect("write clone1");
1241 engine1.stage(&[file1]).await.expect("stage clone1");
1242 engine1.commit("clone1: modify line 2").await.expect("commit clone1");
1243 engine1.push().await.expect("push clone1");
1244
1245 let file2 = clone2.join("data.txt");
1247 tokio::fs::write(&file2, "line1\nchanged_by_clone2\nline3\n")
1248 .await
1249 .expect("write clone2");
1250 engine2.stage(&[file2]).await.expect("stage clone2");
1251 engine2.commit("clone2: modify line 2").await.expect("commit clone2");
1252
1253 let push_result = engine2.push().await.expect("push clone2");
1255 assert!(
1256 matches!(push_result, PushResult::Rejected),
1257 "push clone2 should be Rejected: {push_result:?}"
1258 );
1259
1260 let merge_result = engine2.pull().await.expect("pull clone2");
1262 assert!(
1263 matches!(merge_result, MergeResult::Conflict { .. }),
1264 "same line should conflict: {merge_result:?}"
1265 );
1266 })
1267 .await
1268 .expect("test timed out — possible deadlock");
1269 }
1270
1271 #[tokio::test]
1274 async fn test_fetch_updates_refs() {
1275 eprintln!("[TEST] test_fetch_updates_refs");
1276 tokio::time::timeout(TEST_TIMEOUT, async {
1277 let (_tmp, _bare, clone1, clone2) = create_bare_and_two_clones().await;
1278 let engine1 = GitEngine::new(clone1.clone(), "main".to_string()).expect("engine1");
1279 let engine2 = GitEngine::new(clone2.clone(), "main".to_string()).expect("engine2");
1280
1281 let before = engine2.get_remote_head().await.expect("remote head before");
1283
1284 tokio::fs::write(clone1.join("fetched.txt"), "fetch test")
1286 .await
1287 .expect("write");
1288 engine1.stage(&[clone1.join("fetched.txt")]).await.expect("stage");
1289 engine1.commit("commit to verify fetch").await.expect("commit");
1290 engine1.push().await.expect("push");
1291
1292 let fetch_result = engine2.fetch().await.expect("fetch");
1294 assert!(
1295 matches!(fetch_result, FetchResult::Updated { .. }),
1296 "fetch should return Updated: {fetch_result:?}"
1297 );
1298
1299 let after = engine2.get_remote_head().await.expect("remote head after");
1301 assert_ne!(before, after, "remote ref should update after fetch");
1302
1303 let clone1_head = engine1.get_head_commit().await.expect("clone1 head");
1305 assert_eq!(
1306 after.as_deref(),
1307 Some(clone1_head.as_str()),
1308 "clone2 remote ref should point to clone1 commit"
1309 );
1310 })
1311 .await
1312 .expect("test timed out — possible deadlock");
1313 }
1314}