Skip to main content

omnifuse_git/
ops.rs

1//! High-level git operations.
2//!
3//! Combines low-level git commands into operations
4//! suitable for the VFS sync workflow.
5
6use 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/// High-level git operations.
16#[derive(Debug)]
17pub struct GitOps {
18  /// Underlying git engine.
19  engine: GitEngine
20}
21
22impl GitOps {
23  /// Create a new wrapper for git operations.
24  ///
25  /// # Errors
26  ///
27  /// Returns an error if the repository cannot be opened.
28  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  /// Get the engine.
34  #[must_use]
35  pub const fn engine(&self) -> &GitEngine {
36    &self.engine
37  }
38
39  /// Repository path.
40  #[must_use]
41  pub fn repo_path(&self) -> &Path {
42    self.engine.repo_path()
43  }
44
45  /// Startup synchronization.
46  ///
47  /// Fetch + merge.
48  ///
49  /// # Errors
50  ///
51  /// Returns an error if sync fails.
52  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  /// Commit specific files.
91  ///
92  /// # Errors
93  ///
94  /// Returns an error if the commit fails.
95  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  /// Auto-commit with timestamp.
107  ///
108  /// # Errors
109  ///
110  /// Returns an error if the commit fails.
111  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  /// Push with retries and auto-pull on rejection.
121  ///
122  /// # Errors
123  ///
124  /// Returns an error if push ultimately fails.
125  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  /// Check for remote changes.
160  ///
161  /// # Errors
162  ///
163  /// Returns an error if fetch fails.
164  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/// Startup sync result.
173#[derive(Debug, Clone)]
174pub enum StartupSyncResult {
175  /// Already up to date.
176  UpToDate,
177  /// Updated from remote.
178  Updated,
179  /// Merge commit created.
180  Merged,
181  /// Conflicts detected.
182  Conflicts {
183    /// Files with conflicts.
184    files: Vec<PathBuf>
185  },
186  /// Working offline.
187  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  /// Timeout for async tests (30s — git operations can be slow).
197  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    // Verify commit message
226    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      // clone1 pushes a change to a different file
266      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      // clone2 commits a change (to a different file, no conflict)
272      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      // push_with_retry should: push — rejected — pull — retry — success
277      ops2.push_with_retry(3).await.expect("push_with_retry");
278
279      // Verify both files are present
280      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  /// Full commit_changes flow: create file — commit_changes — git log shows the commit.
304  #[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    // Remember HEAD before the commit
311    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    // Create a file
320    let file = path.join("staged_and_committed.txt");
321    tokio::fs::write(&file, "test data").await.expect("write");
322
323    // commit_changes automatically stages and commits
324    let msg = "test full cycle stage+commit";
325    let hash = ops.commit_changes(&[file], msg).await.expect("commit_changes");
326
327    // Verify hash differs from previous HEAD
328    assert_ne!(hash, head_before, "commit hash should differ from previous HEAD");
329
330    // Verify git log
331    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    // Verify the file is in the commit
344    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  /// auto_commit format: "[auto] N file(s) changed at YYYY-MM-DD HH:MM:SS".
358  /// Verify the count is correct for multiple files.
359  #[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    // Create 3 files
366    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    // Read commit message via git log
378    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    // Verify timestamp in YYYY-MM-DD format
393    assert!(
394      message.contains(&chrono::Local::now().format("%Y-%m-%d").to_string()),
395      "message should contain current date: {message}"
396    );
397  }
398
399  /// commit_changes — git log --oneline contains the commit message.
400  #[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    // git log --oneline should contain the message
413    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    // Verify the short hash from the log is the beginning of the full hash
426    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  /// Sync with an empty file list does not panic but returns an error.
434  #[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    // Empty file list — commit_changes should return an error
441    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  /// Full cycle through bare repo: init — write — stage — commit — push — verify.
450  #[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      // Create a file
458      let file = clone1.join("cycle_test.txt");
459      tokio::fs::write(&file, "full cycle").await.expect("write");
460
461      // Commit
462      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      // Push
469      ops.push_with_retry(1).await.expect("push");
470
471      // Verify the commit reached bare repo
472      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  /// Bare + clone1. File is added in clone1, commit, push.
489  /// Clone2 is created (via new clone). startup_sync — Updated or UpToDate.
490  /// File should be present.
491  #[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      // Add a file to clone1, commit, push
498      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      // Create a new clone (clone3) from bare
510      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      // Configure git config for clone3
520      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      // startup_sync on clone3
534      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      // Verify the file exists in clone3
545      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  /// Create 3 files, commit_changes with all three — one commit.
555  /// git log shows one entry (after the initial one).
556  #[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    // Remember the commit count before
563    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    // Create 3 files
575    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    // Commit all three with a single call
584    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    // Verify exactly 1 commit was added
591    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    // Verify the latest commit message
610    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    // Verify all 3 files are in the commit
623    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}