Skip to main content

omnifuse_git/
sync_lifecycle.rs

1//! Git synchronization lifecycle.
2
3use std::path::{Path, PathBuf};
4
5use omnifuse_core::{RemoteApplyMode, RemoteDeferReason, RemoteRefresh, RemoteRefreshResult};
6use tracing::{debug, info, warn};
7
8use crate::{
9  GitConfig,
10  engine::MergeResult,
11  error::{classify_git_error, is_nothing_to_commit},
12  ops::{GitOps, StartupSyncResult},
13  repo_source::RepoSource,
14  tracking::GitTrackingRules
15};
16
17/// Git startup result.
18#[derive(Debug, Clone)]
19pub enum GitInit {
20  /// Repository is up to date.
21  UpToDate,
22  /// Repository was updated from remote.
23  Updated,
24  /// Startup sync found conflicts.
25  Conflicts {
26    /// Files with conflicts.
27    files: Vec<PathBuf>
28  },
29  /// Remote is unavailable, local state is usable.
30  Offline
31}
32
33/// Git local sync result.
34#[derive(Debug, Clone)]
35pub enum GitSync {
36  /// Local changes were synced.
37  Success {
38    /// Number of synced files.
39    synced_files: usize
40  },
41  /// Sync hit conflicts.
42  Conflict {
43    /// Files with conflicts.
44    files: Vec<PathBuf>
45  },
46  /// Remote is unavailable.
47  Offline
48}
49
50/// Git remote refresh result.
51#[derive(Debug, Clone)]
52pub enum GitRefresh {
53  /// Remote has no changes.
54  NoChange,
55  /// Remote changes were applied.
56  Applied {
57    /// Files changed by remote.
58    files: Vec<PathBuf>,
59    /// Merge result returned by git.
60    merge: MergeResult
61  },
62  /// Refresh hit conflicts.
63  Conflict {
64    /// Files with conflicts.
65    files: Vec<PathBuf>
66  },
67  /// Remote is unavailable.
68  Offline
69}
70
71/// Deep git workflow facade.
72#[derive(Debug)]
73pub struct GitSyncLifecycle {
74  repo_path: PathBuf,
75  ops: GitOps,
76  tracking: GitTrackingRules,
77  max_push_retries: u32
78}
79
80impl GitSyncLifecycle {
81  /// Open a repository and run startup synchronization.
82  ///
83  /// # Errors
84  ///
85  /// Returns an error if the repository cannot be prepared or opened.
86  pub async fn open(config: GitConfig, local_dir: &Path) -> anyhow::Result<(Self, GitInit)> {
87    let target_dir = if config.local_dir.as_os_str().is_empty() {
88      local_dir.to_path_buf()
89    } else {
90      config.local_dir.clone()
91    };
92    let source = RepoSource::parse(&config.source);
93    let repo_path = prepare_repo(&source, &config.branch, &target_dir).await?;
94    let ops = GitOps::new(repo_path.clone(), config.branch)?;
95    let init = map_startup_sync(ops.startup_sync().await?);
96    let tracking = GitTrackingRules::new(&repo_path);
97
98    Ok((
99      Self {
100        repo_path,
101        ops,
102        tracking,
103        max_push_retries: config.max_push_retries
104      },
105      init
106    ))
107  }
108
109  /// Return whether a path should be tracked.
110  #[must_use]
111  pub fn should_track(&self, path: &Path) -> bool {
112    self.tracking.accepts(path)
113  }
114
115  /// Synchronize local dirty files to the Git remote.
116  ///
117  /// # Errors
118  ///
119  /// Returns an error if git commit or push fails with a non-domain error.
120  pub async fn sync_local(&self, dirty_files: &[PathBuf]) -> anyhow::Result<GitSync> {
121    if let Err(error) = self.ops.auto_commit(dirty_files).await {
122      if !is_nothing_to_commit(&error) {
123        return Err(error);
124      }
125      debug!("sync_local: no changes to commit");
126    }
127
128    match self.ops.push_with_retry(self.max_push_retries).await {
129      Ok(()) => Ok(GitSync::Success {
130        synced_files: dirty_files.len()
131      }),
132      Err(error) => match classify_git_error(&error) {
133        Some(omnifuse_core::ErrorKind::Conflict) => {
134          warn!("sync_local: conflicts during push");
135          Ok(GitSync::Conflict {
136            files: dirty_files.to_vec()
137          })
138        }
139        Some(omnifuse_core::ErrorKind::Offline) => Ok(GitSync::Offline),
140        _ => Err(error)
141      }
142    }
143  }
144
145  /// Fetch and apply remote changes.
146  ///
147  /// # Errors
148  ///
149  /// Returns an error if git fetch or pull fails with a non-domain error.
150  pub async fn refresh_remote(&self) -> anyhow::Result<GitRefresh> {
151    let engine = self.ops.engine();
152    let local_head = engine.get_head_commit().await?;
153
154    if let Err(error) = engine.fetch().await {
155      return match classify_git_error(&error) {
156        Some(omnifuse_core::ErrorKind::Offline) => Ok(GitRefresh::Offline),
157        _ => Err(error)
158      };
159    }
160
161    let Some(remote_head) = engine.get_remote_head().await? else {
162      return Ok(GitRefresh::NoChange);
163    };
164
165    if local_head == remote_head {
166      return Ok(GitRefresh::NoChange);
167    }
168
169    let files = self.diff_files_between(&local_head, &remote_head).await?;
170    let merge = engine.pull().await?;
171
172    match merge {
173      MergeResult::Conflict { files } => Ok(GitRefresh::Conflict { files }),
174      merge => Ok(GitRefresh::Applied { files, merge })
175    }
176  }
177
178  /// Detect remote changes, respect protected paths, and apply safe refreshes.
179  ///
180  /// # Errors
181  ///
182  /// Returns an error if git inspection or pull fails with a non-domain error.
183  pub async fn refresh_remote_protected(&self, request: RemoteRefresh<'_>) -> anyhow::Result<RemoteRefreshResult> {
184    let changed_files = match self.changed_remote_files().await {
185      Ok(files) => files,
186      Err(error) => {
187        return match classify_git_error(&error) {
188          Some(omnifuse_core::ErrorKind::Offline) => Ok(RemoteRefreshResult::Offline),
189          _ => Err(error)
190        };
191      }
192    };
193
194    if changed_files.is_empty() {
195      return Ok(RemoteRefreshResult::Unchanged);
196    }
197
198    let protected: Vec<PathBuf> = changed_files
199      .iter()
200      .filter(|path| request.protected_paths.is_protected(path))
201      .cloned()
202      .collect();
203    if !protected.is_empty() {
204      return Ok(RemoteRefreshResult::Deferred {
205        affected: protected,
206        reason: RemoteDeferReason::ProtectedLocalChange
207      });
208    }
209
210    if matches!(request.mode, RemoteApplyMode::DetectOnly) {
211      return Ok(RemoteRefreshResult::Deferred {
212        affected: changed_files,
213        reason: RemoteDeferReason::DetectOnly
214      });
215    }
216
217    match self.refresh_remote().await? {
218      GitRefresh::NoChange => Ok(RemoteRefreshResult::Unchanged),
219      GitRefresh::Applied { files, .. } => Ok(RemoteRefreshResult::Applied {
220        changed: files,
221        deleted: Vec::new()
222      }),
223      GitRefresh::Conflict { files } => Ok(RemoteRefreshResult::Deferred {
224        affected: files,
225        reason: RemoteDeferReason::Conflict
226      }),
227      GitRefresh::Offline => Ok(RemoteRefreshResult::Offline)
228    }
229  }
230
231  /// Classify a git error for core observability.
232  #[must_use]
233  pub fn classify(&self, error: &anyhow::Error) -> omnifuse_core::ErrorKind {
234    classify_git_error(error).unwrap_or(omnifuse_core::ErrorKind::Internal)
235  }
236
237  /// Repository path.
238  #[must_use]
239  pub fn repo_path(&self) -> &Path {
240    &self.repo_path
241  }
242
243  pub(crate) async fn changed_remote_files(&self) -> anyhow::Result<Vec<PathBuf>> {
244    if !self.ops.check_remote().await? {
245      return Ok(Vec::new());
246    }
247
248    self.diff_remote_files().await
249  }
250
251  pub(crate) async fn is_online(&self) -> bool {
252    self.ops.engine().fetch().await.is_ok()
253  }
254
255  async fn diff_remote_files(&self) -> anyhow::Result<Vec<PathBuf>> {
256    let engine = self.ops.engine();
257
258    let local_head = engine.get_head_commit().await?;
259    let remote_head = engine.get_remote_head().await?;
260
261    let Some(remote_head) = remote_head else {
262      return Ok(Vec::new());
263    };
264
265    if local_head == remote_head {
266      return Ok(Vec::new());
267    }
268
269    self.diff_files_between(&local_head, &remote_head).await
270  }
271
272  async fn diff_files_between(&self, from: &str, to: &str) -> anyhow::Result<Vec<PathBuf>> {
273    let output = tokio::process::Command::new("git")
274      .current_dir(&self.repo_path)
275      .args(["diff", "--name-only", from, to])
276      .output()
277      .await?;
278
279    if !output.status.success() {
280      return Ok(Vec::new());
281    }
282
283    Ok(
284      String::from_utf8_lossy(&output.stdout)
285        .lines()
286        .map(|line| self.repo_path.join(line))
287        .collect()
288    )
289  }
290}
291
292async fn prepare_repo(source: &RepoSource, branch: &str, target_dir: &Path) -> anyhow::Result<PathBuf> {
293  match source {
294    RepoSource::Local(path) => {
295      let target_is_inside_repo = target_dir.starts_with(path) || target_dir == path;
296      if target_is_inside_repo {
297        return source.ensure_available(branch).await;
298      }
299
300      std::fs::create_dir_all(target_dir)?;
301      if !target_dir.join(".git").exists() {
302        info!(source = %path.display(), target = %target_dir.display(), "cloning local repo into cache");
303        let output = tokio::process::Command::new("git")
304          .args(["clone", "--branch", branch])
305          .arg(path)
306          .arg(target_dir)
307          .output()
308          .await?;
309
310        if !output.status.success() {
311          let stderr = String::from_utf8_lossy(&output.stderr);
312          anyhow::bail!("git clone failed: {stderr}");
313        }
314      }
315
316      Ok(target_dir.to_path_buf())
317    }
318    RepoSource::Remote { .. } => source.ensure_available_at(branch, target_dir).await
319  }
320}
321
322fn map_startup_sync(result: StartupSyncResult) -> GitInit {
323  match result {
324    StartupSyncResult::UpToDate => GitInit::UpToDate,
325    StartupSyncResult::Updated | StartupSyncResult::Merged => GitInit::Updated,
326    StartupSyncResult::Conflicts { files } => GitInit::Conflicts { files },
327    StartupSyncResult::Offline => GitInit::Offline
328  }
329}
330
331#[cfg(test)]
332#[allow(clippy::expect_used)]
333mod tests {
334  use std::path::Path;
335
336  use crate::{
337    GitConfig,
338    engine::{GitEngine, tests::create_bare_and_two_clones},
339    sync_lifecycle::{GitInit, GitSync, GitSyncLifecycle}
340  };
341
342  #[tokio::test]
343  async fn open_local_repo_runs_startup_sync_and_tracking() {
344    let (_tmp, _bare, repo_path, _other) = create_bare_and_two_clones().await;
345    let config = GitConfig {
346      source: repo_path.to_string_lossy().into_owned(),
347      branch: "main".to_string(),
348      max_push_retries: 3,
349      poll_interval_secs: 30,
350      local_dir: repo_path.clone()
351    };
352
353    let (git, init) = GitSyncLifecycle::open(config, &repo_path).await.expect("open");
354
355    assert!(matches!(init, GitInit::UpToDate | GitInit::Updated));
356    assert!(git.should_track(Path::new("README.md")));
357    assert!(!git.should_track(Path::new(".git/config")));
358  }
359
360  #[tokio::test]
361  async fn sync_local_commits_and_pushes_dirty_files() {
362    let (_tmp, _bare, clone1, _clone2) = create_bare_and_two_clones().await;
363    let config = GitConfig {
364      source: clone1.to_string_lossy().into_owned(),
365      branch: "main".to_string(),
366      max_push_retries: 3,
367      poll_interval_secs: 30,
368      local_dir: clone1.clone()
369    };
370    let (git, _) = GitSyncLifecycle::open(config, &clone1).await.expect("open");
371    let file = clone1.join("new.txt");
372    tokio::fs::write(&file, "new").await.expect("write");
373
374    let result = git.sync_local(&[file]).await.expect("sync");
375
376    assert!(matches!(result, GitSync::Success { synced_files: 1 }));
377  }
378
379  #[tokio::test]
380  async fn sync_local_reports_noop_as_success() {
381    let (_tmp, _bare, repo_path, _other) = create_bare_and_two_clones().await;
382    let config = GitConfig {
383      source: repo_path.to_string_lossy().into_owned(),
384      branch: "main".to_string(),
385      max_push_retries: 1,
386      poll_interval_secs: 30,
387      local_dir: repo_path.clone()
388    };
389    let (git, _) = GitSyncLifecycle::open(config, &repo_path).await.expect("open");
390
391    let result = git.sync_local(&[repo_path.join("README.md")]).await.expect("sync");
392
393    assert!(matches!(result, GitSync::Success { synced_files: 1 }));
394  }
395
396  #[tokio::test]
397  async fn refresh_remote_applies_new_remote_commit() {
398    let (_tmp, _bare, clone1, clone2) = create_bare_and_two_clones().await;
399    let config = GitConfig {
400      source: clone1.to_string_lossy().into_owned(),
401      branch: "main".to_string(),
402      max_push_retries: 3,
403      poll_interval_secs: 30,
404      local_dir: clone1.clone()
405    };
406    let (git, _) = GitSyncLifecycle::open(config, &clone1).await.expect("open");
407
408    tokio::fs::write(clone2.join("remote.txt"), "remote")
409      .await
410      .expect("write");
411    let engine2 = GitEngine::new(clone2.clone(), "main".to_string()).expect("engine2");
412    engine2.stage(&[clone2.join("remote.txt")]).await.expect("stage");
413    engine2.commit("remote change").await.expect("commit");
414    engine2.push().await.expect("push");
415
416    let result = git.refresh_remote().await.expect("refresh");
417
418    assert!(matches!(result, super::GitRefresh::Applied { .. }));
419    assert!(clone1.join("remote.txt").exists());
420  }
421}