1use 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#[derive(Debug, Clone)]
19pub enum GitInit {
20 UpToDate,
22 Updated,
24 Conflicts {
26 files: Vec<PathBuf>
28 },
29 Offline
31}
32
33#[derive(Debug, Clone)]
35pub enum GitSync {
36 Success {
38 synced_files: usize
40 },
41 Conflict {
43 files: Vec<PathBuf>
45 },
46 Offline
48}
49
50#[derive(Debug, Clone)]
52pub enum GitRefresh {
53 NoChange,
55 Applied {
57 files: Vec<PathBuf>,
59 merge: MergeResult
61 },
62 Conflict {
64 files: Vec<PathBuf>
66 },
67 Offline
69}
70
71#[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 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 #[must_use]
111 pub fn should_track(&self, path: &Path) -> bool {
112 self.tracking.accepts(path)
113 }
114
115 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 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 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 #[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 #[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}