Skip to main content

git_sync_rs/
sync.rs

1mod transport;
2
3use crate::error::{Result, SyncError};
4use chrono::Local;
5use git2::{BranchType, MergeOptions, Oid, Repository, Status, StatusOptions};
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use tracing::{debug, error, info, warn};
10
11pub use transport::{CommandGitTransport, CommitOutcome, GitTransport};
12
13/// Prefix for fallback branches created by git-sync
14pub const FALLBACK_BRANCH_PREFIX: &str = "git-sync/";
15
16/// Configuration for the synchronizer
17#[derive(Debug, Clone)]
18pub struct SyncConfig {
19    /// Whether to sync new/untracked files
20    pub sync_new_files: bool,
21
22    /// Whether to skip git hooks when committing
23    pub skip_hooks: bool,
24
25    /// Custom commit message (can include {hostname} and {timestamp} placeholders)
26    pub commit_message: Option<String>,
27
28    /// Remote name to sync with (e.g., "origin")
29    pub remote_name: String,
30
31    /// Branch name to sync (current working branch)
32    pub branch_name: String,
33
34    /// When true, create a fallback branch on merge conflicts instead of failing
35    pub conflict_branch: bool,
36
37    /// The target branch we want to track (used for returning from fallback)
38    /// If None, defaults to the repository's default branch
39    pub target_branch: Option<String>,
40}
41
42impl Default for SyncConfig {
43    fn default() -> Self {
44        Self {
45            sync_new_files: true, // Default to syncing untracked files
46            skip_hooks: false,
47            commit_message: None,
48            remote_name: "origin".to_string(),
49            branch_name: "main".to_string(),
50            conflict_branch: false,
51            target_branch: None,
52        }
53    }
54}
55
56/// Repository state that might prevent syncing
57#[derive(Debug, Clone, PartialEq)]
58pub enum RepositoryState {
59    /// Repository is clean and ready
60    Clean,
61
62    /// Repository has uncommitted changes
63    Dirty,
64
65    /// Repository is in the middle of a rebase
66    Rebasing,
67
68    /// Repository is in the middle of a merge
69    Merging,
70
71    /// Repository is cherry-picking
72    CherryPicking,
73
74    /// Repository is bisecting
75    Bisecting,
76
77    /// Repository is applying patches (git am)
78    ApplyingPatches,
79
80    /// Repository is in the middle of a revert
81    Reverting,
82
83    /// HEAD is detached
84    DetachedHead,
85}
86
87/// Sync state relative to remote
88#[derive(Debug, Clone, PartialEq)]
89pub enum SyncState {
90    /// Local and remote are equal
91    Equal,
92
93    /// Local is ahead of remote
94    Ahead(usize),
95
96    /// Local is behind remote
97    Behind(usize),
98
99    /// Local and remote have diverged
100    Diverged { ahead: usize, behind: usize },
101
102    /// No upstream branch
103    NoUpstream,
104}
105
106/// Unhandled file state that prevents sync
107#[derive(Debug, Clone, PartialEq)]
108pub enum UnhandledFileState {
109    /// File has merge conflicts
110    Conflicted { path: String },
111}
112
113/// State for tracking fallback branch return attempts (in-memory only)
114#[derive(Debug, Clone, Default)]
115pub struct FallbackState {
116    /// The OID of the target branch when we last checked if return was possible
117    /// Used to avoid redundant merge checks when target hasn't moved
118    pub last_checked_target_oid: Option<Oid>,
119}
120
121/// Main synchronizer struct
122pub struct RepositorySynchronizer {
123    repo: Repository,
124    config: SyncConfig,
125    _repo_path: PathBuf,
126    fallback_state: FallbackState,
127    transport: Arc<dyn GitTransport>,
128}
129
130impl RepositorySynchronizer {
131    /// Create a new synchronizer for the given repository path
132    pub fn new(repo_path: impl AsRef<Path>, config: SyncConfig) -> Result<Self> {
133        Self::new_with_transport(repo_path, config, Arc::new(CommandGitTransport))
134    }
135
136    /// Create a new synchronizer with explicit git transport implementation
137    pub fn new_with_transport(
138        repo_path: impl AsRef<Path>,
139        config: SyncConfig,
140        transport: Arc<dyn GitTransport>,
141    ) -> Result<Self> {
142        let repo_path = repo_path.as_ref().to_path_buf();
143        let repo = Repository::open(&repo_path).map_err(|_| SyncError::NotARepository {
144            path: repo_path.display().to_string(),
145        })?;
146
147        Ok(Self {
148            repo,
149            config,
150            _repo_path: repo_path,
151            fallback_state: FallbackState::default(),
152            transport,
153        })
154    }
155
156    /// Create a new synchronizer with auto-detected branch name
157    pub fn new_with_detected_branch(
158        repo_path: impl AsRef<Path>,
159        config: SyncConfig,
160    ) -> Result<Self> {
161        Self::new_with_detected_branch_and_transport(
162            repo_path,
163            config,
164            Arc::new(CommandGitTransport),
165        )
166    }
167
168    /// Create a new synchronizer with auto-detected branch and explicit git transport
169    pub fn new_with_detected_branch_and_transport(
170        repo_path: impl AsRef<Path>,
171        mut config: SyncConfig,
172        transport: Arc<dyn GitTransport>,
173    ) -> Result<Self> {
174        let repo_path = repo_path.as_ref().to_path_buf();
175        let repo = Repository::open(&repo_path).map_err(|_| SyncError::NotARepository {
176            path: repo_path.display().to_string(),
177        })?;
178
179        // Try to detect current branch
180        if let Ok(head) = repo.head() {
181            if head.is_branch() {
182                if let Some(branch_name) = head.shorthand() {
183                    config.branch_name = branch_name.to_string();
184                }
185            }
186        }
187
188        Ok(Self {
189            repo,
190            config,
191            _repo_path: repo_path,
192            fallback_state: FallbackState::default(),
193            transport,
194        })
195    }
196
197    /// Get the current repository state
198    pub fn get_repository_state(&self) -> Result<RepositoryState> {
199        // Check if HEAD is detached
200        match self.repo.head_detached() {
201            Ok(true) => return Ok(RepositoryState::DetachedHead),
202            Ok(false) => {}
203            // Unborn branches are valid in bootstrap flows.
204            Err(e) if e.code() == git2::ErrorCode::UnbornBranch => {}
205            Err(e) => return Err(e.into()),
206        }
207
208        // Check for various in-progress operations
209        let state = self.repo.state();
210        match state {
211            git2::RepositoryState::Clean => {
212                // Check if working directory is dirty
213                let mut status_opts = StatusOptions::new();
214                status_opts.include_untracked(true);
215                let statuses = self.repo.statuses(Some(&mut status_opts))?;
216
217                if statuses.is_empty() {
218                    Ok(RepositoryState::Clean)
219                } else {
220                    Ok(RepositoryState::Dirty)
221                }
222            }
223            git2::RepositoryState::Merge => Ok(RepositoryState::Merging),
224            git2::RepositoryState::Rebase
225            | git2::RepositoryState::RebaseInteractive
226            | git2::RepositoryState::RebaseMerge => Ok(RepositoryState::Rebasing),
227            git2::RepositoryState::CherryPick | git2::RepositoryState::CherryPickSequence => {
228                Ok(RepositoryState::CherryPicking)
229            }
230            git2::RepositoryState::Revert | git2::RepositoryState::RevertSequence => {
231                Ok(RepositoryState::Reverting)
232            }
233            git2::RepositoryState::Bisect => Ok(RepositoryState::Bisecting),
234            git2::RepositoryState::ApplyMailbox | git2::RepositoryState::ApplyMailboxOrRebase => {
235                Ok(RepositoryState::ApplyingPatches)
236            }
237        }
238    }
239
240    /// Check if there are local changes that need to be committed
241    pub fn has_local_changes(&self) -> Result<bool> {
242        let mut status_opts = StatusOptions::new();
243        status_opts.include_untracked(self.config.sync_new_files);
244
245        let statuses = self.repo.statuses(Some(&mut status_opts))?;
246
247        for entry in statuses.iter() {
248            let status = entry.status();
249            let tracked_or_staged_changes = Status::WT_MODIFIED
250                | Status::WT_DELETED
251                | Status::WT_RENAMED
252                | Status::WT_TYPECHANGE
253                | Status::INDEX_MODIFIED
254                | Status::INDEX_DELETED
255                | Status::INDEX_RENAMED
256                | Status::INDEX_TYPECHANGE
257                | Status::INDEX_NEW;
258
259            if self.config.sync_new_files {
260                // Check for any changes including new files
261                if status.intersects(tracked_or_staged_changes | Status::WT_NEW) {
262                    return Ok(true);
263                }
264            } else {
265                // Only check for modifications to tracked files
266                if status.intersects(tracked_or_staged_changes) {
267                    return Ok(true);
268                }
269            }
270        }
271
272        Ok(false)
273    }
274
275    /// Check if there are unhandled file states that should prevent sync
276    pub fn check_unhandled_files(&self) -> Result<Option<UnhandledFileState>> {
277        let mut status_opts = StatusOptions::new();
278        status_opts.include_untracked(true);
279
280        let statuses = self.repo.statuses(Some(&mut status_opts))?;
281
282        for entry in statuses.iter() {
283            let status = entry.status();
284            let path = entry.path().unwrap_or("<unknown>").to_string();
285
286            // Check for conflicted files
287            if status.is_conflicted() {
288                return Ok(Some(UnhandledFileState::Conflicted { path }));
289            }
290        }
291
292        Ok(None)
293    }
294
295    /// Get the current branch name
296    pub fn get_current_branch(&self) -> Result<String> {
297        let head = match self.repo.head() {
298            Ok(head) => head,
299            Err(e) if e.code() == git2::ErrorCode::UnbornBranch => {
300                if let Some(branch) = self.unborn_head_branch_name()? {
301                    return Ok(branch);
302                }
303                if !self.config.branch_name.is_empty() {
304                    return Ok(self.config.branch_name.clone());
305                }
306                return Err(SyncError::Other(
307                    "Repository HEAD is unborn and branch name could not be determined".to_string(),
308                ));
309            }
310            Err(e) => return Err(e.into()),
311        };
312
313        if !head.is_branch() {
314            return Err(SyncError::DetachedHead);
315        }
316
317        let branch_name = head
318            .shorthand()
319            .ok_or_else(|| SyncError::Other("Could not get branch name".to_string()))?;
320
321        Ok(branch_name.to_string())
322    }
323
324    /// Get the sync state relative to the remote
325    pub fn get_sync_state(&self) -> Result<SyncState> {
326        let branch_name = self.get_current_branch()?;
327        let local_branch = self.repo.find_branch(&branch_name, BranchType::Local)?;
328
329        // Get the upstream branch
330        let upstream = match local_branch.upstream() {
331            Ok(upstream) => upstream,
332            Err(_) => return Ok(SyncState::NoUpstream),
333        };
334
335        // Get the OIDs for comparison
336        let local_oid = local_branch
337            .get()
338            .target()
339            .ok_or_else(|| SyncError::Other("Could not get local branch OID".to_string()))?;
340        let upstream_oid = upstream
341            .get()
342            .target()
343            .ok_or_else(|| SyncError::Other("Could not get upstream branch OID".to_string()))?;
344
345        // If they're the same, we're in sync
346        if local_oid == upstream_oid {
347            return Ok(SyncState::Equal);
348        }
349
350        // Count commits ahead and behind
351        let (ahead, behind) = self.repo.graph_ahead_behind(local_oid, upstream_oid)?;
352
353        match (ahead, behind) {
354            (0, 0) => Ok(SyncState::Equal),
355            (a, 0) if a > 0 => Ok(SyncState::Ahead(a)),
356            (0, b) if b > 0 => Ok(SyncState::Behind(b)),
357            (a, b) if a > 0 && b > 0 => Ok(SyncState::Diverged {
358                ahead: a,
359                behind: b,
360            }),
361            _ => Ok(SyncState::Equal),
362        }
363    }
364
365    /// Auto-commit local changes
366    pub fn auto_commit(&self) -> Result<()> {
367        info!("Auto-committing local changes");
368
369        // Stage changes
370        let mut index = self.repo.index()?;
371
372        if self.config.sync_new_files {
373            // Add all changes including new files
374            index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?;
375        } else {
376            // Only update tracked files
377            index.update_all(["*"].iter(), None)?;
378        }
379
380        index.write()?;
381
382        // Prepare commit message
383        let message = if let Some(ref msg) = self.config.commit_message {
384            msg.replace("{hostname}", &hostname::get()?.to_string_lossy())
385                .replace(
386                    "{timestamp}",
387                    &Local::now().format("%Y-%m-%d %I:%M:%S %p %Z").to_string(),
388                )
389        } else {
390            format!(
391                "changes from {} on {}",
392                hostname::get()?.to_string_lossy(),
393                Local::now().format("%Y-%m-%d %I:%M:%S %p %Z")
394            )
395        };
396
397        match self
398            .transport
399            .commit(&self._repo_path, &message, self.config.skip_hooks)?
400        {
401            CommitOutcome::Created => info!("Created auto-commit: {}", message),
402            CommitOutcome::NoChanges => {
403                debug!("No changes to commit");
404            }
405        }
406
407        Ok(())
408    }
409
410    /// Fetch a specific branch from remote
411    pub fn fetch_branch(&self, branch: &str) -> Result<()> {
412        info!(
413            "Fetching branch {} from remote: {}",
414            branch, self.config.remote_name
415        );
416
417        if let Err(e) =
418            self.transport
419                .fetch_branch(&self._repo_path, &self.config.remote_name, branch)
420        {
421            error!("Git fetch failed: {}", e);
422            return Err(e);
423        }
424
425        info!(
426            "Fetch completed successfully for branch {} from remote: {}",
427            branch, self.config.remote_name
428        );
429        Ok(())
430    }
431
432    /// Fetch from remote
433    pub fn fetch(&self) -> Result<()> {
434        let current_branch = self.get_current_branch()?;
435        self.fetch_branch(&current_branch)?;
436
437        // If we're on a fallback branch and have a target branch, also fetch that
438        if self.config.conflict_branch {
439            if let Ok(target) = self.get_target_branch() {
440                if target != current_branch {
441                    // Ignore errors fetching target - it might not be necessary
442                    let _ = self.fetch_branch(&target);
443                }
444            }
445        }
446
447        Ok(())
448    }
449
450    /// Push to remote
451    pub fn push(&self) -> Result<()> {
452        info!("Pushing to remote: {}", self.config.remote_name);
453
454        let current_branch = self.get_current_branch()?;
455        let refspec = format!("{}:{}", current_branch, current_branch);
456        self.transport
457            .push_refspec(&self._repo_path, &self.config.remote_name, &refspec)?;
458
459        info!(
460            "Push completed successfully to remote: {}",
461            self.config.remote_name
462        );
463        Ok(())
464    }
465
466    /// Perform a fast-forward merge
467    pub fn fast_forward_merge(&self) -> Result<()> {
468        info!("Performing fast-forward merge");
469
470        let branch_name = self.get_current_branch()?;
471        let local_branch = self.repo.find_branch(&branch_name, BranchType::Local)?;
472        let upstream = local_branch.upstream()?;
473
474        let upstream_oid = upstream
475            .get()
476            .target()
477            .ok_or_else(|| SyncError::Other("Could not get upstream OID".to_string()))?;
478
479        // Fast-forward by moving the reference
480        let mut reference = self.repo.head()?;
481        reference.set_target(upstream_oid, "fast-forward merge")?;
482
483        // Checkout the new HEAD to update working directory
484        let object = self.repo.find_object(upstream_oid, None)?;
485        let mut checkout_builder = git2::build::CheckoutBuilder::new();
486        checkout_builder.force(); // Force update working directory files
487        self.repo
488            .checkout_tree(&object, Some(&mut checkout_builder))?;
489
490        // Update HEAD to point to the new commit
491        self.repo.set_head(&format!("refs/heads/{}", branch_name))?;
492
493        info!("Fast-forward merge completed - working tree updated");
494        Ok(())
495    }
496
497    /// Perform a rebase
498    pub fn rebase(&self) -> Result<()> {
499        info!("Performing rebase");
500
501        let branch_name = self.get_current_branch()?;
502        let local_branch = self.repo.find_branch(&branch_name, BranchType::Local)?;
503        let upstream = local_branch.upstream()?;
504
505        let upstream_commit = upstream.get().peel_to_commit()?;
506        let local_commit = local_branch.get().peel_to_commit()?;
507
508        // Find merge base
509        let merge_base = self
510            .repo
511            .merge_base(local_commit.id(), upstream_commit.id())?;
512        let _merge_base_commit = self.repo.find_commit(merge_base)?;
513
514        // Create signature
515        let sig = self.repo.signature()?;
516
517        // Get annotated commits from references
518        let local_annotated = self
519            .repo
520            .reference_to_annotated_commit(local_branch.get())?;
521        let upstream_annotated = self.repo.reference_to_annotated_commit(upstream.get())?;
522
523        // Start rebase
524        let mut rebase = self.repo.rebase(
525            Some(&local_annotated),
526            Some(&upstream_annotated),
527            None,
528            None,
529        )?;
530
531        // Process each commit
532        while let Some(operation) = rebase.next() {
533            let _operation = operation?;
534
535            // Check if we can continue
536            if self.repo.index()?.has_conflicts() {
537                warn!("Conflicts detected during rebase");
538                rebase.abort()?;
539
540                // If conflict_branch is enabled, create a fallback branch
541                if self.config.conflict_branch {
542                    return self.handle_conflict_with_fallback();
543                }
544
545                return Err(SyncError::ManualInterventionRequired {
546                    reason: "Rebase conflicts detected. Please resolve manually.".to_string(),
547                });
548            }
549
550            // Continue with the rebase
551            rebase.commit(None, &sig, None)?;
552        }
553
554        // Finish the rebase
555        rebase.finish(Some(&sig))?;
556
557        // Ensure working tree is properly updated after rebase
558        let head = self.repo.head()?;
559        let head_commit = head.peel_to_commit()?;
560        let mut checkout_builder = git2::build::CheckoutBuilder::new();
561        checkout_builder.force();
562        self.repo
563            .checkout_tree(head_commit.as_object(), Some(&mut checkout_builder))?;
564
565        info!("Rebase completed successfully - working tree updated");
566        Ok(())
567    }
568
569    /// Detect the repository's default branch
570    pub fn detect_default_branch(&self) -> Result<String> {
571        // Try to get the default branch from origin/HEAD
572        if let Ok(reference) = self.repo.find_reference("refs/remotes/origin/HEAD") {
573            if let Ok(resolved) = reference.resolve() {
574                if let Some(name) = resolved.shorthand() {
575                    // name will be like "origin/main", extract just "main"
576                    if let Some(branch) = name.strip_prefix("origin/") {
577                        debug!("Detected default branch from origin/HEAD: {}", branch);
578                        return Ok(branch.to_string());
579                    }
580                }
581            }
582        }
583
584        // Fallback: check if main or master exists
585        if self.repo.find_branch("main", BranchType::Local).is_ok()
586            || self.repo.find_reference("refs/remotes/origin/main").is_ok()
587        {
588            debug!("Falling back to 'main' as default branch");
589            return Ok("main".to_string());
590        }
591
592        if self.repo.find_branch("master", BranchType::Local).is_ok()
593            || self
594                .repo
595                .find_reference("refs/remotes/origin/master")
596                .is_ok()
597        {
598            debug!("Falling back to 'master' as default branch");
599            return Ok("master".to_string());
600        }
601
602        // Last resort: use current branch
603        self.get_current_branch()
604    }
605
606    /// Get the target branch (the branch we want to be on)
607    pub fn get_target_branch(&self) -> Result<String> {
608        if let Some(ref target) = self.config.target_branch {
609            if !target.is_empty() {
610                return Ok(target.clone());
611            }
612        }
613        self.detect_default_branch()
614    }
615
616    /// Check if we're currently on a fallback branch
617    pub fn is_on_fallback_branch(&self) -> Result<bool> {
618        let current = self.get_current_branch()?;
619        Ok(current.starts_with(FALLBACK_BRANCH_PREFIX))
620    }
621
622    /// Generate a fallback branch name
623    fn generate_fallback_branch_name() -> String {
624        let hostname = hostname::get()
625            .map(|h| h.to_string_lossy().to_string())
626            .unwrap_or_else(|_| "unknown".to_string());
627        let timestamp = Local::now().format("%Y-%m-%d-%H%M%S");
628        format!("{}{}-{}", FALLBACK_BRANCH_PREFIX, hostname, timestamp)
629    }
630
631    /// Create and switch to a fallback branch
632    pub fn create_fallback_branch(&self) -> Result<String> {
633        let branch_name = Self::generate_fallback_branch_name();
634        info!("Creating fallback branch: {}", branch_name);
635
636        // Get current HEAD commit
637        let head_commit = self.repo.head()?.peel_to_commit()?;
638
639        // Create the new branch
640        self.repo
641            .branch(&branch_name, &head_commit, false)
642            .map_err(|e| SyncError::Other(format!("Failed to create fallback branch: {}", e)))?;
643
644        // Checkout the new branch
645        let refname = format!("refs/heads/{}", branch_name);
646        self.repo.set_head(&refname)?;
647
648        // Update working directory
649        let mut checkout_builder = git2::build::CheckoutBuilder::new();
650        checkout_builder.force();
651        self.repo
652            .checkout_head(Some(&mut checkout_builder))
653            .map_err(|e| SyncError::Other(format!("Failed to checkout fallback branch: {}", e)))?;
654
655        info!("Switched to fallback branch: {}", branch_name);
656        Ok(branch_name)
657    }
658
659    /// Push a branch to remote (used for fallback branches)
660    pub fn push_branch(&self, branch_name: &str) -> Result<()> {
661        info!("Pushing branch {} to remote", branch_name);
662
663        self.transport.push_branch_upstream(
664            &self._repo_path,
665            &self.config.remote_name,
666            branch_name,
667        )?;
668
669        info!("Successfully pushed branch {} to remote", branch_name);
670        Ok(())
671    }
672
673    /// Check if merging target branch into current HEAD would succeed (in-memory, no working tree changes)
674    pub fn can_merge_cleanly(&self, target_branch: &str) -> Result<bool> {
675        // Get the target branch reference
676        let target_ref = format!("refs/remotes/{}/{}", self.config.remote_name, target_branch);
677        let target_reference = self.repo.find_reference(&target_ref).map_err(|e| {
678            SyncError::Other(format!(
679                "Failed to find target branch {}: {}",
680                target_branch, e
681            ))
682        })?;
683        let target_commit = target_reference.peel_to_commit()?;
684
685        // Get current HEAD
686        let head_commit = self.repo.head()?.peel_to_commit()?;
687
688        // Check if we're already ancestors (fast-forward possible)
689        if self
690            .repo
691            .graph_descendant_of(target_commit.id(), head_commit.id())?
692        {
693            debug!(
694                "Target branch {} is descendant of current HEAD, clean merge possible",
695                target_branch
696            );
697            return Ok(true);
698        }
699
700        // Perform in-memory merge to check for conflicts
701        let merge_opts = MergeOptions::new();
702        let index = self
703            .repo
704            .merge_commits(&head_commit, &target_commit, Some(&merge_opts))
705            .map_err(|e| SyncError::Other(format!("Failed to perform merge check: {}", e)))?;
706
707        let has_conflicts = index.has_conflicts();
708        debug!("In-memory merge check: has_conflicts={}", has_conflicts);
709
710        Ok(!has_conflicts)
711    }
712
713    /// Get the OID of the target branch on remote
714    fn get_target_branch_oid(&self, target_branch: &str) -> Result<Oid> {
715        let target_ref = format!("refs/remotes/{}/{}", self.config.remote_name, target_branch);
716        let reference = self.repo.find_reference(&target_ref)?;
717        reference
718            .target()
719            .ok_or_else(|| SyncError::Other("Target branch has no OID".to_string()))
720    }
721
722    /// Attempt to return to the target branch from a fallback branch
723    pub fn try_return_to_target(&mut self) -> Result<bool> {
724        if !self.is_on_fallback_branch()? {
725            return Ok(false);
726        }
727
728        let target_branch = self.get_target_branch()?;
729        info!(
730            "On fallback branch, checking if we can return to {}",
731            target_branch
732        );
733
734        // Get current target branch OID
735        let target_oid = match self.get_target_branch_oid(&target_branch) {
736            Ok(oid) => oid,
737            Err(e) => {
738                warn!("Could not find target branch {}: {}", target_branch, e);
739                return Ok(false);
740            }
741        };
742
743        // Check if target has moved since last check
744        if let Some(last_checked) = self.fallback_state.last_checked_target_oid {
745            if last_checked == target_oid {
746                debug!(
747                    "Target branch {} hasn't changed since last check, skipping merge check",
748                    target_branch
749                );
750                return Ok(false);
751            }
752        }
753
754        // Target has moved, check if we can merge cleanly
755        if !self.can_merge_cleanly(&target_branch)? {
756            info!(
757                "Cannot cleanly merge {} into current branch, staying on fallback",
758                target_branch
759            );
760            self.fallback_state.last_checked_target_oid = Some(target_oid);
761            return Ok(false);
762        }
763
764        info!(
765            "Clean merge possible, returning to target branch {}",
766            target_branch
767        );
768
769        // Get current branch commits that need to be rebased onto target
770        let current_branch = self.get_current_branch()?;
771        let current_oid = self
772            .repo
773            .head()?
774            .target()
775            .ok_or_else(|| SyncError::Other("Current HEAD has no OID".to_string()))?;
776
777        // Find merge base between our fallback branch and target
778        let merge_base = self.repo.merge_base(current_oid, target_oid)?;
779
780        // Check if we have commits to rebase
781        let (ahead, _) = self.repo.graph_ahead_behind(current_oid, merge_base)?;
782        let has_commits_to_rebase = ahead > 0;
783
784        // Checkout target branch
785        let target_ref = format!("refs/heads/{}", target_branch);
786
787        // First, make sure local target branch exists and is up to date
788        let remote_target_ref =
789            format!("refs/remotes/{}/{}", self.config.remote_name, target_branch);
790        let remote_target = self.repo.find_reference(&remote_target_ref)?;
791        let remote_target_oid = remote_target
792            .target()
793            .ok_or_else(|| SyncError::Other("Remote target has no OID".to_string()))?;
794
795        // Update or create local target branch
796        if self.repo.find_reference(&target_ref).is_ok() {
797            // Update existing branch
798            self.repo.reference(
799                &target_ref,
800                remote_target_oid,
801                true,
802                "git-sync: updating target branch before return",
803            )?;
804        } else {
805            // Create local tracking branch
806            let remote_commit = self.repo.find_commit(remote_target_oid)?;
807            self.repo.branch(&target_branch, &remote_commit, false)?;
808        }
809
810        // Checkout target branch
811        self.repo.set_head(&target_ref)?;
812        let mut checkout_builder = git2::build::CheckoutBuilder::new();
813        checkout_builder.force();
814        self.repo.checkout_head(Some(&mut checkout_builder))?;
815
816        if has_commits_to_rebase {
817            info!(
818                "Rebasing {} commits from {} onto {}",
819                ahead, current_branch, target_branch
820            );
821
822            // We need to rebase our commits from the fallback branch onto target
823            // Get the commits from the fallback branch
824            let fallback_ref = format!("refs/heads/{}", current_branch);
825            let fallback_reference = self.repo.find_reference(&fallback_ref)?;
826            let fallback_annotated = self
827                .repo
828                .reference_to_annotated_commit(&fallback_reference)?;
829
830            let target_reference = self.repo.find_reference(&target_ref)?;
831            let target_annotated = self.repo.reference_to_annotated_commit(&target_reference)?;
832
833            let sig = self.repo.signature()?;
834
835            // Start rebase
836            let mut rebase = self.repo.rebase(
837                Some(&fallback_annotated),
838                Some(&target_annotated),
839                None,
840                None,
841            )?;
842
843            // Process each commit
844            while let Some(operation) = rebase.next() {
845                let _operation = operation?;
846
847                if self.repo.index()?.has_conflicts() {
848                    warn!("Conflicts during rebase back to target, aborting");
849                    rebase.abort()?;
850                    // Switch back to fallback branch
851                    self.repo.set_head(&fallback_ref)?;
852                    self.repo.checkout_head(Some(&mut checkout_builder))?;
853                    self.fallback_state.last_checked_target_oid = Some(target_oid);
854                    return Ok(false);
855                }
856
857                rebase.commit(None, &sig, None)?;
858            }
859
860            rebase.finish(Some(&sig))?;
861
862            // Update working tree
863            let head = self.repo.head()?;
864            let head_commit = head.peel_to_commit()?;
865            self.repo
866                .checkout_tree(head_commit.as_object(), Some(&mut checkout_builder))?;
867        }
868
869        // Clear fallback state
870        self.fallback_state.last_checked_target_oid = None;
871
872        info!("Successfully returned to target branch {}", target_branch);
873        Ok(true)
874    }
875
876    /// Handle a rebase conflict by creating a fallback branch (when conflict_branch is enabled)
877    fn handle_conflict_with_fallback(&self) -> Result<()> {
878        if !self.config.conflict_branch {
879            return Err(SyncError::ManualInterventionRequired {
880                reason: "Rebase conflicts detected. Please resolve manually.".to_string(),
881            });
882        }
883
884        info!("Conflict detected with conflict_branch enabled, creating fallback branch");
885
886        // Create fallback branch from current state
887        let fallback_branch = self.create_fallback_branch()?;
888
889        // Commit any uncommitted changes on the fallback branch
890        if self.has_local_changes()? {
891            self.auto_commit()?;
892        }
893
894        // Push the fallback branch
895        self.push_branch(&fallback_branch)?;
896
897        info!(
898            "Switched to fallback branch {} due to conflicts. \
899             Will automatically return to target branch when conflicts are resolved.",
900            fallback_branch
901        );
902
903        Ok(())
904    }
905
906    /// Main sync operation
907    pub fn sync(&mut self, check_only: bool) -> Result<()> {
908        info!("Starting sync operation (check_only: {})", check_only);
909
910        // Check repository state
911        let repo_state = self.get_repository_state()?;
912        match repo_state {
913            RepositoryState::Clean | RepositoryState::Dirty => {
914                // These states are OK to continue
915            }
916            RepositoryState::DetachedHead => {
917                return Err(SyncError::DetachedHead);
918            }
919            _ => {
920                return Err(SyncError::UnsafeRepositoryState {
921                    state: format!("{:?}", repo_state),
922                });
923            }
924        }
925
926        // Check for unhandled files
927        if let Some(unhandled) = self.check_unhandled_files()? {
928            let reason = match unhandled {
929                UnhandledFileState::Conflicted { path } => format!("Conflicted file: {}", path),
930            };
931            return Err(SyncError::ManualInterventionRequired { reason });
932        }
933
934        // If we're only checking, we're done
935        if check_only {
936            info!("Check passed, sync can proceed");
937            return Ok(());
938        }
939
940        // Bootstrap flow for freshly cloned empty repositories.
941        // In this state HEAD points to a branch name but has no commit yet.
942        if self.is_head_unborn()? {
943            info!("Repository HEAD is unborn; attempting initial publish");
944            if self.has_local_changes()? {
945                self.auto_commit()?;
946                let branch = self.get_current_branch()?;
947                self.push_branch(&branch)?;
948            } else {
949                info!("HEAD is unborn and there are no local changes to publish");
950            }
951            return Ok(());
952        }
953
954        // Fetch from remote first (needed for both normal sync and return-to-target check)
955        self.fetch()?;
956
957        // If we're on a fallback branch and conflict_branch is enabled,
958        // try to return to the target branch
959        if self.config.conflict_branch
960            && self.is_on_fallback_branch()?
961            && self.try_return_to_target()?
962        {
963            // Successfully returned to target, update branch name for sync state check
964            info!("Returned to target branch, continuing with normal sync");
965        }
966
967        // Auto-commit if there are local changes
968        if self.has_local_changes()? {
969            self.auto_commit()?;
970        }
971
972        // Get sync state and handle accordingly
973        let sync_state = self.get_sync_state()?;
974        match sync_state {
975            SyncState::Equal => {
976                info!("Already in sync");
977            }
978            SyncState::Ahead(_) => {
979                info!("Local is ahead, pushing");
980                self.push()?;
981            }
982            SyncState::Behind(_) => {
983                info!("Local is behind, fast-forwarding");
984                self.fast_forward_merge()?;
985            }
986            SyncState::Diverged { .. } => {
987                info!("Branches have diverged, rebasing");
988                self.rebase()?;
989                self.push()?;
990            }
991            SyncState::NoUpstream => {
992                // If we're on a fallback branch that doesn't have upstream yet, push it
993                if self.is_on_fallback_branch()? {
994                    info!("Fallback branch has no upstream, pushing");
995                    let branch = self.get_current_branch()?;
996                    self.push_branch(&branch)?;
997                } else {
998                    let branch = self
999                        .get_current_branch()
1000                        .unwrap_or_else(|_| "<unknown>".into());
1001                    return Err(SyncError::NoRemoteConfigured { branch });
1002                }
1003            }
1004        }
1005
1006        // Verify we're in sync (skip for fallback branches that may not have upstream yet)
1007        let final_state = self.get_sync_state()?;
1008        if final_state != SyncState::Equal && final_state != SyncState::NoUpstream {
1009            warn!(
1010                "Sync completed but repository is not in sync: {:?}",
1011                final_state
1012            );
1013            return Err(SyncError::Other(
1014                "Sync completed but repository is not in sync".to_string(),
1015            ));
1016        }
1017
1018        info!("Sync completed successfully");
1019        Ok(())
1020    }
1021
1022    /// Returns true when HEAD points at an unborn branch (no commit yet).
1023    fn is_head_unborn(&self) -> Result<bool> {
1024        match self.repo.head() {
1025            Ok(head) => match head.peel_to_commit() {
1026                Ok(_) => Ok(false),
1027                Err(e) if e.code() == git2::ErrorCode::UnbornBranch => Ok(true),
1028                Err(e) => Err(e.into()),
1029            },
1030            Err(e) if e.code() == git2::ErrorCode::UnbornBranch => Ok(true),
1031            Err(e) => Err(e.into()),
1032        }
1033    }
1034
1035    fn unborn_head_branch_name(&self) -> Result<Option<String>> {
1036        let head_path = self.repo.path().join("HEAD");
1037        let head_contents = fs::read_to_string(head_path)?;
1038        Ok(head_contents
1039            .trim()
1040            .strip_prefix("ref: refs/heads/")
1041            .map(|s| s.to_string()))
1042    }
1043}