1use crate::error::{Result, SyncError};
2use chrono::Local;
3use git2::{BranchType, Repository, Status, StatusOptions};
4use std::path::{Path, PathBuf};
5use tracing::{debug, error, info, warn};
6
7#[derive(Debug, Clone)]
9pub struct SyncConfig {
10 pub sync_new_files: bool,
12
13 pub skip_hooks: bool,
15
16 pub commit_message: Option<String>,
18
19 pub remote_name: String,
21
22 pub branch_name: String,
24}
25
26impl Default for SyncConfig {
27 fn default() -> Self {
28 Self {
29 sync_new_files: true, skip_hooks: false,
31 commit_message: None,
32 remote_name: "origin".to_string(),
33 branch_name: "main".to_string(),
34 }
35 }
36}
37
38#[derive(Debug, Clone, PartialEq)]
40pub enum RepositoryState {
41 Clean,
43
44 Dirty,
46
47 Rebasing,
49
50 Merging,
52
53 CherryPicking,
55
56 Bisecting,
58
59 ApplyingPatches,
61
62 DetachedHead,
64}
65
66#[derive(Debug, Clone, PartialEq)]
68pub enum SyncState {
69 Equal,
71
72 Ahead(usize),
74
75 Behind(usize),
77
78 Diverged { ahead: usize, behind: usize },
80
81 NoUpstream,
83}
84
85#[derive(Debug, Clone, PartialEq)]
87pub enum UnhandledFileState {
88 Conflicted { path: String },
90}
91
92pub struct RepositorySynchronizer {
94 repo: Repository,
95 config: SyncConfig,
96 _repo_path: PathBuf,
97}
98
99impl RepositorySynchronizer {
100 pub fn new(repo_path: impl AsRef<Path>, config: SyncConfig) -> Result<Self> {
102 let repo_path = repo_path.as_ref().to_path_buf();
103 let repo = Repository::open(&repo_path).map_err(|_| SyncError::NotARepository {
104 path: repo_path.display().to_string(),
105 })?;
106
107 Ok(Self {
108 repo,
109 config,
110 _repo_path: repo_path,
111 })
112 }
113
114 pub fn new_with_detected_branch(
116 repo_path: impl AsRef<Path>,
117 mut config: SyncConfig,
118 ) -> Result<Self> {
119 let repo_path = repo_path.as_ref().to_path_buf();
120 let repo = Repository::open(&repo_path).map_err(|_| SyncError::NotARepository {
121 path: repo_path.display().to_string(),
122 })?;
123
124 if let Ok(head) = repo.head() {
126 if head.is_branch() {
127 if let Some(branch_name) = head.shorthand() {
128 config.branch_name = branch_name.to_string();
129 }
130 }
131 }
132
133 Ok(Self {
134 repo,
135 config,
136 _repo_path: repo_path,
137 })
138 }
139
140 pub fn get_repository_state(&self) -> Result<RepositoryState> {
142 if self.repo.head_detached()? {
144 return Ok(RepositoryState::DetachedHead);
145 }
146
147 let state = self.repo.state();
149 match state {
150 git2::RepositoryState::Clean => {
151 let mut status_opts = StatusOptions::new();
153 status_opts.include_untracked(true);
154 let statuses = self.repo.statuses(Some(&mut status_opts))?;
155
156 if statuses.is_empty() {
157 Ok(RepositoryState::Clean)
158 } else {
159 Ok(RepositoryState::Dirty)
160 }
161 }
162 git2::RepositoryState::Merge => Ok(RepositoryState::Merging),
163 git2::RepositoryState::Rebase
164 | git2::RepositoryState::RebaseInteractive
165 | git2::RepositoryState::RebaseMerge => Ok(RepositoryState::Rebasing),
166 git2::RepositoryState::CherryPick | git2::RepositoryState::CherryPickSequence => {
167 Ok(RepositoryState::CherryPicking)
168 }
169 git2::RepositoryState::Bisect => Ok(RepositoryState::Bisecting),
170 git2::RepositoryState::ApplyMailbox | git2::RepositoryState::ApplyMailboxOrRebase => {
171 Ok(RepositoryState::ApplyingPatches)
172 }
173 _ => Ok(RepositoryState::Clean),
174 }
175 }
176
177 pub fn has_local_changes(&self) -> Result<bool> {
179 let mut status_opts = StatusOptions::new();
180 status_opts.include_untracked(self.config.sync_new_files);
181
182 let statuses = self.repo.statuses(Some(&mut status_opts))?;
183
184 for entry in statuses.iter() {
185 let status = entry.status();
186
187 if self.config.sync_new_files {
188 if status.intersects(
190 Status::WT_MODIFIED
191 | Status::WT_DELETED
192 | Status::WT_RENAMED
193 | Status::WT_TYPECHANGE
194 | Status::WT_NEW,
195 ) {
196 return Ok(true);
197 }
198 } else {
199 if status.intersects(
201 Status::WT_MODIFIED
202 | Status::WT_DELETED
203 | Status::WT_RENAMED
204 | Status::WT_TYPECHANGE,
205 ) {
206 return Ok(true);
207 }
208 }
209 }
210
211 Ok(false)
212 }
213
214 pub fn check_unhandled_files(&self) -> Result<Option<UnhandledFileState>> {
216 let mut status_opts = StatusOptions::new();
217 status_opts.include_untracked(true);
218
219 let statuses = self.repo.statuses(Some(&mut status_opts))?;
220
221 for entry in statuses.iter() {
222 let status = entry.status();
223 let path = entry.path().unwrap_or("<unknown>").to_string();
224
225 if status.is_conflicted() {
227 return Ok(Some(UnhandledFileState::Conflicted { path }));
228 }
229 }
230
231 Ok(None)
232 }
233
234 pub fn get_current_branch(&self) -> Result<String> {
236 let head = self.repo.head()?;
237
238 if !head.is_branch() {
239 return Err(SyncError::DetachedHead);
240 }
241
242 let branch_name = head
243 .shorthand()
244 .ok_or_else(|| SyncError::Other("Could not get branch name".to_string()))?;
245
246 Ok(branch_name.to_string())
247 }
248
249 pub fn get_sync_state(&self) -> Result<SyncState> {
251 let branch_name = self.get_current_branch()?;
252 let local_branch = self.repo.find_branch(&branch_name, BranchType::Local)?;
253
254 let upstream = match local_branch.upstream() {
256 Ok(upstream) => upstream,
257 Err(_) => return Ok(SyncState::NoUpstream),
258 };
259
260 let local_oid = local_branch
262 .get()
263 .target()
264 .ok_or_else(|| SyncError::Other("Could not get local branch OID".to_string()))?;
265 let upstream_oid = upstream
266 .get()
267 .target()
268 .ok_or_else(|| SyncError::Other("Could not get upstream branch OID".to_string()))?;
269
270 if local_oid == upstream_oid {
272 return Ok(SyncState::Equal);
273 }
274
275 let (ahead, behind) = self.repo.graph_ahead_behind(local_oid, upstream_oid)?;
277
278 match (ahead, behind) {
279 (0, 0) => Ok(SyncState::Equal),
280 (a, 0) if a > 0 => Ok(SyncState::Ahead(a)),
281 (0, b) if b > 0 => Ok(SyncState::Behind(b)),
282 (a, b) if a > 0 && b > 0 => Ok(SyncState::Diverged {
283 ahead: a,
284 behind: b,
285 }),
286 _ => Ok(SyncState::Equal),
287 }
288 }
289
290 pub fn auto_commit(&self) -> Result<()> {
292 info!("Auto-committing local changes");
293
294 let mut index = self.repo.index()?;
296
297 if self.config.sync_new_files {
298 index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?;
300 } else {
301 index.update_all(["*"].iter(), None)?;
303 }
304
305 index.write()?;
306
307 let tree_id = index.write_tree()?;
309 let tree = self.repo.find_tree(tree_id)?;
310
311 let parent_commit = self.repo.head()?.peel_to_commit()?;
312 if parent_commit.tree_id() == tree_id {
313 debug!("No changes to commit");
314 return Ok(());
315 }
316
317 let message = if let Some(ref msg) = self.config.commit_message {
319 msg.replace("{hostname}", &hostname::get()?.to_string_lossy())
320 .replace(
321 "{timestamp}",
322 &Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
323 )
324 } else {
325 format!(
326 "changes from {} on {}",
327 hostname::get()?.to_string_lossy(),
328 Local::now().format("%Y-%m-%d %H:%M:%S")
329 )
330 };
331
332 let sig = self.repo.signature()?;
334
335 self.repo
337 .commit(Some("HEAD"), &sig, &sig, &message, &tree, &[&parent_commit])?;
338
339 info!("Created auto-commit: {}", message);
340 Ok(())
341 }
342
343 pub fn fetch(&self) -> Result<()> {
345 info!("Fetching from remote: {}", self.config.remote_name);
346
347 use std::process::Command;
349
350 let output = Command::new("git")
351 .arg("fetch")
352 .arg(&self.config.remote_name)
353 .arg(&self.config.branch_name)
354 .current_dir(&self._repo_path)
355 .output()
356 .map_err(|e| SyncError::Other(format!("Failed to run git fetch: {}", e)))?;
357
358 if !output.status.success() {
359 let stderr = String::from_utf8_lossy(&output.stderr);
360 error!("Git fetch failed: {}", stderr);
361 return Err(SyncError::Other(format!("Git fetch failed: {}", stderr)));
362 }
363
364 info!(
365 "Fetch completed successfully from remote: {}",
366 self.config.remote_name
367 );
368 return Ok(());
369
370 #[allow(unreachable_code)]
372 {
373 let mut remote = self.repo.find_remote(&self.config.remote_name)?;
374
375 if let Some(url) = remote.url() {
377 debug!("Remote URL: {}", url);
378 }
379
380 let mut callbacks = git2::RemoteCallbacks::new();
382 callbacks.credentials(|url, username_from_url, allowed_types| {
383 debug!(
384 "Authentication callback: url={}, username={:?}, allowed_types={:?}",
385 url, username_from_url, allowed_types
386 );
387
388 let username = username_from_url.unwrap_or("git");
389
390 debug!("Trying SSH key from agent with username: {}", username);
392 match git2::Cred::ssh_key_from_agent(username) {
393 Ok(cred) => {
394 debug!("Successfully obtained SSH credentials from agent");
395 return Ok(cred);
396 }
397 Err(e) => {
398 debug!("SSH agent failed: {}, trying default SSH key", e);
399 }
400 }
401
402 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
404 let ssh_dir = std::path::Path::new(&home).join(".ssh");
405 let private_key = ssh_dir.join("id_rsa");
406 let public_key = ssh_dir.join("id_rsa.pub");
407
408 if private_key.exists() {
410 debug!("Trying SSH key from {:?}", private_key);
411 match git2::Cred::ssh_key(username, Some(&public_key), &private_key, None) {
412 Ok(cred) => {
413 debug!("Successfully using SSH key from disk");
414 return Ok(cred);
415 }
416 Err(e) => {
417 debug!("Failed to use id_rsa: {}", e);
418 }
419 }
420 }
421
422 let private_key = ssh_dir.join("id_ed25519");
424 let public_key = ssh_dir.join("id_ed25519.pub");
425 if private_key.exists() {
426 debug!("Trying SSH key from {:?}", private_key);
427 match git2::Cred::ssh_key(username, Some(&public_key), &private_key, None) {
428 Ok(cred) => {
429 debug!("Successfully using ed25519 SSH key from disk");
430 return Ok(cred);
431 }
432 Err(e) => {
433 debug!("Failed to use id_ed25519: {}", e);
434 }
435 }
436 }
437
438 error!("No working SSH authentication method found");
439 Err(git2::Error::from_str(
440 "No SSH authentication method available",
441 ))
442 });
443
444 callbacks.transfer_progress(|stats| {
446 debug!(
447 "Fetch progress: {}/{} objects, {} bytes received",
448 stats.received_objects(),
449 stats.total_objects(),
450 stats.received_bytes()
451 );
452 true
453 });
454
455 let mut fetch_options = git2::FetchOptions::new();
456 fetch_options.remote_callbacks(callbacks);
457
458 let mut proxy_options = git2::ProxyOptions::new();
460 proxy_options.auto();
461 fetch_options.proxy_options(proxy_options);
462
463 debug!("Starting fetch for branch: {}", self.config.branch_name);
464 debug!(
465 "Fetching refspec: refs/heads/{}:refs/remotes/{}/{}",
466 self.config.branch_name, self.config.remote_name, self.config.branch_name
467 );
468
469 match remote.fetch(&[&self.config.branch_name], Some(&mut fetch_options), None) {
471 Ok(_) => {
472 info!(
473 "Fetch completed successfully from remote: {}",
474 self.config.remote_name
475 );
476 Ok(())
477 }
478 Err(e) => {
479 error!(
480 "Fetch failed from remote {}: {}",
481 self.config.remote_name, e
482 );
483 Err(e.into())
484 }
485 }
486 }
487 }
488
489 pub fn push(&self) -> Result<()> {
491 info!("Pushing to remote: {}", self.config.remote_name);
492
493 use std::process::Command;
495
496 let refspec = format!("{}:{}", self.config.branch_name, self.config.branch_name);
497
498 let output = Command::new("git")
499 .arg("push")
500 .arg(&self.config.remote_name)
501 .arg(&refspec)
502 .current_dir(&self._repo_path)
503 .output()
504 .map_err(|e| SyncError::Other(format!("Failed to run git push: {}", e)))?;
505
506 if !output.status.success() {
507 let stderr = String::from_utf8_lossy(&output.stderr);
508 error!("Git push failed: {}", stderr);
509 return Err(SyncError::Other(format!("Git push failed: {}", stderr)));
510 }
511
512 info!(
513 "Push completed successfully to remote: {}",
514 self.config.remote_name
515 );
516 return Ok(());
517
518 #[allow(unreachable_code)]
520 {
521 let mut remote = self.repo.find_remote(&self.config.remote_name)?;
522
523 let mut callbacks = git2::RemoteCallbacks::new();
525 callbacks.credentials(|url, username_from_url, allowed_types| {
526 debug!(
527 "Authentication callback: url={}, username={:?}, allowed_types={:?}",
528 url, username_from_url, allowed_types
529 );
530
531 let username = username_from_url.unwrap_or("git");
532
533 debug!("Trying SSH key from agent with username: {}", username);
535 match git2::Cred::ssh_key_from_agent(username) {
536 Ok(cred) => {
537 debug!("Successfully obtained SSH credentials from agent");
538 return Ok(cred);
539 }
540 Err(e) => {
541 debug!("SSH agent failed: {}, trying default SSH key", e);
542 }
543 }
544
545 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
547 let ssh_dir = std::path::Path::new(&home).join(".ssh");
548 let private_key = ssh_dir.join("id_rsa");
549 let public_key = ssh_dir.join("id_rsa.pub");
550
551 if private_key.exists() {
553 debug!("Trying SSH key from {:?}", private_key);
554 match git2::Cred::ssh_key(username, Some(&public_key), &private_key, None) {
555 Ok(cred) => {
556 debug!("Successfully using SSH key from disk");
557 return Ok(cred);
558 }
559 Err(e) => {
560 debug!("Failed to use id_rsa: {}", e);
561 }
562 }
563 }
564
565 let private_key = ssh_dir.join("id_ed25519");
567 let public_key = ssh_dir.join("id_ed25519.pub");
568 if private_key.exists() {
569 debug!("Trying SSH key from {:?}", private_key);
570 match git2::Cred::ssh_key(username, Some(&public_key), &private_key, None) {
571 Ok(cred) => {
572 debug!("Successfully using ed25519 SSH key from disk");
573 return Ok(cred);
574 }
575 Err(e) => {
576 debug!("Failed to use id_ed25519: {}", e);
577 }
578 }
579 }
580
581 error!("No working SSH authentication method found");
582 Err(git2::Error::from_str(
583 "No SSH authentication method available",
584 ))
585 });
586
587 let mut push_options = git2::PushOptions::new();
588 push_options.remote_callbacks(callbacks);
589
590 let mut proxy_options = git2::ProxyOptions::new();
592 proxy_options.auto();
593 push_options.proxy_options(proxy_options);
594
595 let refspec = format!(
597 "refs/heads/{}:refs/heads/{}",
598 self.config.branch_name, self.config.branch_name
599 );
600
601 debug!("Pushing refspec: {}", refspec);
602 match remote.push(&[&refspec], Some(&mut push_options)) {
603 Ok(_) => {
604 info!(
605 "Push completed successfully to remote: {}",
606 self.config.remote_name
607 );
608 Ok(())
609 }
610 Err(e) => {
611 error!("Push failed to remote {}: {}", self.config.remote_name, e);
612 Err(e.into())
613 }
614 }
615 }
616 }
617
618 pub fn fast_forward_merge(&self) -> Result<()> {
620 info!("Performing fast-forward merge");
621
622 let branch_name = self.get_current_branch()?;
623 let local_branch = self.repo.find_branch(&branch_name, BranchType::Local)?;
624 let upstream = local_branch.upstream()?;
625
626 let upstream_oid = upstream
627 .get()
628 .target()
629 .ok_or_else(|| SyncError::Other("Could not get upstream OID".to_string()))?;
630
631 let mut reference = self.repo.head()?;
633 reference.set_target(upstream_oid, "fast-forward merge")?;
634
635 let object = self.repo.find_object(upstream_oid, None)?;
637 let mut checkout_builder = git2::build::CheckoutBuilder::new();
638 checkout_builder.force(); self.repo
640 .checkout_tree(&object, Some(&mut checkout_builder))?;
641
642 self.repo.set_head(&format!("refs/heads/{}", branch_name))?;
644
645 info!("Fast-forward merge completed - working tree updated");
646 Ok(())
647 }
648
649 pub fn rebase(&self) -> Result<()> {
651 info!("Performing rebase");
652
653 let branch_name = self.get_current_branch()?;
654 let local_branch = self.repo.find_branch(&branch_name, BranchType::Local)?;
655 let upstream = local_branch.upstream()?;
656
657 let upstream_commit = upstream.get().peel_to_commit()?;
658 let local_commit = local_branch.get().peel_to_commit()?;
659
660 let merge_base = self
662 .repo
663 .merge_base(local_commit.id(), upstream_commit.id())?;
664 let _merge_base_commit = self.repo.find_commit(merge_base)?;
665
666 let sig = self.repo.signature()?;
668
669 let local_annotated = self
671 .repo
672 .reference_to_annotated_commit(local_branch.get())?;
673 let upstream_annotated = self.repo.reference_to_annotated_commit(upstream.get())?;
674
675 let mut rebase = self.repo.rebase(
677 Some(&local_annotated),
678 Some(&upstream_annotated),
679 None,
680 None,
681 )?;
682
683 while let Some(operation) = rebase.next() {
685 let _operation = operation?;
686
687 if self.repo.index()?.has_conflicts() {
689 warn!("Conflicts detected during rebase");
690 rebase.abort()?;
691 return Err(SyncError::ManualInterventionRequired {
692 reason: "Rebase conflicts detected. Please resolve manually.".to_string(),
693 });
694 }
695
696 rebase.commit(None, &sig, None)?;
698 }
699
700 rebase.finish(Some(&sig))?;
702
703 let head = self.repo.head()?;
705 let head_commit = head.peel_to_commit()?;
706 let mut checkout_builder = git2::build::CheckoutBuilder::new();
707 checkout_builder.force();
708 self.repo
709 .checkout_tree(head_commit.as_object(), Some(&mut checkout_builder))?;
710
711 info!("Rebase completed successfully - working tree updated");
712 Ok(())
713 }
714
715 pub fn sync(&self, check_only: bool) -> Result<()> {
717 info!("Starting sync operation (check_only: {})", check_only);
718
719 let repo_state = self.get_repository_state()?;
721 match repo_state {
722 RepositoryState::Clean | RepositoryState::Dirty => {
723 }
725 RepositoryState::DetachedHead => {
726 return Err(SyncError::DetachedHead);
727 }
728 _ => {
729 return Err(SyncError::UnsafeRepositoryState {
730 state: format!("{:?}", repo_state),
731 });
732 }
733 }
734
735 if let Some(unhandled) = self.check_unhandled_files()? {
737 let reason = match unhandled {
738 UnhandledFileState::Conflicted { path } => format!("Conflicted file: {}", path),
739 };
740 return Err(SyncError::ManualInterventionRequired { reason });
741 }
742
743 if check_only {
745 info!("Check passed, sync can proceed");
746 return Ok(());
747 }
748
749 if self.has_local_changes()? {
751 self.auto_commit()?;
752 }
753
754 self.fetch()?;
756
757 let sync_state = self.get_sync_state()?;
759 match sync_state {
760 SyncState::Equal => {
761 info!("Already in sync");
762 }
763 SyncState::Ahead(_) => {
764 info!("Local is ahead, pushing");
765 self.push()?;
766 }
767 SyncState::Behind(_) => {
768 info!("Local is behind, fast-forwarding");
769 self.fast_forward_merge()?;
770 }
771 SyncState::Diverged { .. } => {
772 info!("Branches have diverged, rebasing");
773 self.rebase()?;
774 self.push()?;
776 }
777 SyncState::NoUpstream => {
778 return Err(SyncError::NoRemoteConfigured {
779 branch: self.config.branch_name.clone(),
780 });
781 }
782 }
783
784 let final_state = self.get_sync_state()?;
786 if final_state != SyncState::Equal {
787 warn!(
788 "Sync completed but repository is not in sync: {:?}",
789 final_state
790 );
791 return Err(SyncError::Other(
792 "Sync completed but repository is not in sync".to_string(),
793 ));
794 }
795
796 info!("Sync completed successfully");
797 Ok(())
798 }
799}