Skip to main content

omnifuse_git/
engine.rs

1//! Git engine — wrapper over git CLI.
2//!
3//! Ported from `SimpleGitFS` `core/src/git/engine.rs`.
4
5use 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/// Fetch result.
16#[derive(Debug, Clone)]
17pub enum FetchResult {
18  /// No changes.
19  UpToDate,
20  /// New commits received.
21  Updated {
22    /// Number of commits.
23    commits: usize
24  }
25}
26
27/// Push result.
28#[derive(Debug, Clone)]
29pub enum PushResult {
30  /// Push succeeded.
31  Success,
32  /// Push rejected (pull required).
33  Rejected,
34  /// No remote configured.
35  NoRemote
36}
37
38/// Merge result.
39#[derive(Debug, Clone)]
40pub enum MergeResult {
41  /// Already up to date.
42  UpToDate,
43  /// Fast-forward merge.
44  FastForward,
45  /// Merge commit created.
46  Merged {
47    /// Merge commit hash.
48    commit: String
49  },
50  /// Conflicts detected.
51  Conflict {
52    /// Files with conflicts.
53    files: Vec<PathBuf>
54  }
55}
56
57/// Git engine for repository operations.
58#[derive(Debug)]
59pub struct GitEngine {
60  /// Repository path.
61  repo_path: PathBuf,
62  /// Tracked branch.
63  branch: String,
64  /// Remote name.
65  remote: String,
66  /// Lock for serializing git operations.
67  op_lock: Arc<RwLock<()>>
68}
69
70impl GitEngine {
71  /// Create a new git engine.
72  ///
73  /// # Errors
74  ///
75  /// Returns an error if the path is not a git repository.
76  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  /// Repository path.
91  #[must_use]
92  pub fn repo_path(&self) -> &Path {
93    &self.repo_path
94  }
95
96  /// Current branch.
97  #[must_use]
98  pub fn branch(&self) -> &str {
99    &self.branch
100  }
101
102  /// Add files to the staging area.
103  ///
104  /// # Errors
105  ///
106  /// Returns an error if `git add` fails.
107  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  /// Create a commit.
138  ///
139  /// # Errors
140  ///
141  /// Returns an error if `git commit` fails.
142  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  /// Get the HEAD commit hash.
172  ///
173  /// # Errors
174  ///
175  /// Returns an error if `git rev-parse` fails.
176  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  /// Get the remote HEAD commit hash.
198  ///
199  /// # Errors
200  ///
201  /// Returns an error if `git rev-parse` fails.
202  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  /// Fetch from remote.
219  ///
220  /// # Errors
221  ///
222  /// Returns an error if fetch fails.
223  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  /// Pull from remote (fetch + rebase).
266  ///
267  /// # Errors
268  ///
269  /// Returns an error if pull fails.
270  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        // Abort rebase on conflicts
289        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  /// Push to remote.
323  ///
324  /// # Errors
325  ///
326  /// Returns an error if push fails.
327  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  /// Get the list of files with conflicts.
360  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  /// Check for uncommitted changes.
380  ///
381  /// # Errors
382  ///
383  /// Returns an error if `git status` fails.
384  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  /// Get the list of modified files.
406  ///
407  /// # Errors
408  ///
409  /// Returns an error if `git status` fails.
410  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  /// Timeout for async tests (30s — git operations can be slow).
451  const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
452
453  /// Create a test git repository with an initial commit.
454  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    // git init (explicit branch name to avoid dependence on global config)
459    tokio::process::Command::new("git")
460      .current_dir(&path)
461      .args(["init", "-b", "main"])
462      .output()
463      .await
464      .expect("git init");
465
466    // git config (for commits in tests)
467    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    // Initial commit
482    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  /// Create a bare repo + two clones.
500  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    // Create bare repo (explicit branch name to avoid dependence on global config)
509    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    // Clone 1
517    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    // Configure clone 1
526    for clone_path in [&clone1_path, &clone2_path] {
527      if clone_path == &clone1_path {
528        // Initial commit in clone 1
529        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    // Clone 2
567    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    // Configure clone 2
576    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    // Create a new file and stage it
616    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    // Verify via git status
622    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    // Repository without remote — push should return an error
714    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      // Create a commit and push
726      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      // Commit and push from clone1
744      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      // Pull from clone2
753      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      // Verify the file appeared
761      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  /// Two clones edit different lines of the same file — auto-merge on pull.
768  #[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      // Base file: three lines, created in clone1
777      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      // Sync clone2
786      engine2.pull().await.expect("pull sync");
787
788      // Clone1 modifies line 1
789      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      // Clone2 modifies line 3
797      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      // Pull in clone2 — should auto-merge without conflicts
805      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      // Verify both edits are present
812      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  /// Two clones edit the same line — conflict on pull.
821  #[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      // Base file: three lines, created in clone1
830      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      // Sync clone2
839      engine2.pull().await.expect("pull sync");
840
841      // Clone1 modifies line 1
842      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      // Clone2 also modifies line 1
850      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      // Pull in clone2 — should return a conflict
858      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  /// Fetch without new commits on remote returns UpToDate.
869  #[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      // Fetch without any new commits
877      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  /// One clone deletes a file, another edits it — conflict.
888  #[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      // Create shared file file.txt in clone1
898      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      // Sync clone2
906      engine2.pull().await.expect("pull sync");
907
908      // Clone 1 deletes the file and pushes
909      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      // Clone 2 edits the same file
916      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      // Push will be rejected
923      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      // Pull — conflict (delete vs edit)
930      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  /// modified_files returns the correct list after editing.
941  #[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    // Modify file (README.md created in create_test_repo)
948    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  /// Commit without staging — error (nothing committed).
960  #[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    // Commit without changes in the index — should return an error
967    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  /// Create a file, modify it — modified_files() returns the specific path.
977  #[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    // Create and commit a file
984    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    // Modify the file
990    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  /// After the initial commit, get_head_commit() returns a valid hex SHA-1.
1000  #[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    // Verify all characters are valid hex
1009    assert!(
1010      sha.chars().all(|c| c.is_ascii_hexdigit()),
1011      "SHA-1 should contain only hex characters: {sha}"
1012    );
1013  }
1014
1015  /// Reverse of test_delete_vs_edit_conflict: one clone edits a file,
1016  /// another deletes it — conflict on pull (edit vs delete).
1017  #[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      // Create shared file in clone1
1027      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      // Sync clone2
1035      engine2.pull().await.expect("pull sync");
1036
1037      // Clone 1 EDITS the file and pushes
1038      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      // Clone 2 DELETES the file
1047      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      // Push will be rejected
1055      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      // Pull — conflict (edit vs delete)
1062      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  /// Two clones of one bare repo: both commit to different files, one pushes,
1073  /// second push rejected — pull (rebase) — retry push — success.
1074  #[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      // Clone1 commits and pushes file A
1084      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      // Clone2 commits file B (unaware of file_a.txt)
1093      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      // Push from clone2 — rejected (remote changed)
1100      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      // Pull (rebase) — fetch changes from clone1
1107      let merge = engine2.pull().await.expect("pull");
1108      assert!(
1109        !matches!(merge, MergeResult::Conflict { .. }),
1110        "different files should not conflict: {merge:?}"
1111      );
1112
1113      // Retry push — should succeed now
1114      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      // Verify both files exist in clone2
1121      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  /// After a commit, has_changes returns false.
1132  #[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    // Create a new file
1139    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    // Stage + commit
1143    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  /// Two clones edit different lines of the same file.
1153  /// Clone1 — line 1, clone2 — line 3.
1154  /// Push clone1, push clone2 (rejected), pull clone2 — auto-merge without conflicts.
1155  #[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      // Create a file with three lines in clone1, push
1164      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      // Sync clone2
1173      engine2.pull().await.expect("pull sync");
1174
1175      // Clone1 modifies line 1 — "modified1"
1176      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      // Clone2 modifies line 3 — "modified3"
1184      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      // Push clone2 — should be rejected
1192      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      // Pull clone2 — auto-merge (different lines)
1199      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      // Verify final content — both edits present
1206      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  /// Two clones edit the same line of the same file.
1216  /// Push clone1, push clone2 (rejected), pull clone2 — conflict.
1217  #[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      // Create a file with three lines in clone1, push
1226      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      // Sync clone2
1235      engine2.pull().await.expect("pull sync");
1236
1237      // Clone1 modifies line 2
1238      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      // Clone2 also modifies line 2 (conflict)
1246      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      // Push clone2 — rejected
1254      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      // Pull clone2 — conflict (same line)
1261      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  /// Clone1 pushes a commit, clone2 fetches.
1272  /// After fetch, clone2 refs should update (origin/main points to the new commit).
1273  #[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      // Remember clone2 remote HEAD before fetch
1282      let before = engine2.get_remote_head().await.expect("remote head before");
1283
1284      // Clone1 creates a commit and pushes
1285      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      // Clone2 fetches
1293      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      // Clone2 remote HEAD should change after fetch
1300      let after = engine2.get_remote_head().await.expect("remote head after");
1301      assert_ne!(before, after, "remote ref should update after fetch");
1302
1303      // Clone2 remote HEAD should match clone1 HEAD
1304      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}