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            //
375            // libgit2 can surface untracked nested repositories (e.g. git worktrees) as a
376            // directory path with a trailing slash like `.worktrees/foo/`. Those directory
377            // marker paths are not valid index paths, so attempting to add them fails with:
378            //   invalid path: '.../'; class=Index (10)
379            //
380            // Use an `add_all` callback to skip these directory markers, and (when they look
381            // like nested repos) skip their contents too.
382            let repo_root = self._repo_path.clone();
383            let mut nested_repo_prefixes: Vec<String> = Vec::new();
384            let mut cb = |path: &Path, _matched_spec: &[u8]| -> i32 {
385                let path_s = path.to_string_lossy();
386
387                // Skip anything under a nested git repo we already detected.
388                if nested_repo_prefixes.iter().any(|p| path_s.starts_with(p)) {
389                    return 1;
390                }
391
392                // Avoid ever attempting to add `.git` internals (for nested repos).
393                if path_s.contains("/.git/") || path_s.ends_with("/.git") {
394                    return 1;
395                }
396
397                // Skip directory markers (they are not valid index paths). If it looks like a
398                // nested git repo, remember the prefix so we skip its contents too.
399                if path_s.ends_with('/') {
400                    let no_slash = path_s.trim_end_matches('/');
401                    if repo_root.join(no_slash).join(".git").exists() {
402                        nested_repo_prefixes.push(format!("{}/", no_slash));
403                    }
404                    return 1;
405                }
406
407                0
408            };
409
410            index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, Some(&mut cb))?;
411        } else {
412            // Only update tracked files
413            index.update_all(["*"].iter(), None)?;
414        }
415
416        index.write()?;
417
418        // Prepare commit message
419        let message = if let Some(ref msg) = self.config.commit_message {
420            msg.replace("{hostname}", &hostname::get()?.to_string_lossy())
421                .replace(
422                    "{timestamp}",
423                    &Local::now().format("%Y-%m-%d %I:%M:%S %p %Z").to_string(),
424                )
425        } else {
426            format!(
427                "changes from {} on {}",
428                hostname::get()?.to_string_lossy(),
429                Local::now().format("%Y-%m-%d %I:%M:%S %p %Z")
430            )
431        };
432
433        match self
434            .transport
435            .commit(&self._repo_path, &message, self.config.skip_hooks)?
436        {
437            CommitOutcome::Created => info!("Created auto-commit: {}", message),
438            CommitOutcome::NoChanges => {
439                debug!("No changes to commit");
440            }
441        }
442
443        Ok(())
444    }
445
446    /// Fetch a specific branch from remote
447    pub fn fetch_branch(&self, branch: &str) -> Result<()> {
448        info!(
449            "Fetching branch {} from remote: {}",
450            branch, self.config.remote_name
451        );
452
453        if let Err(e) =
454            self.transport
455                .fetch_branch(&self._repo_path, &self.config.remote_name, branch)
456        {
457            error!("Git fetch failed: {}", e);
458            return Err(e);
459        }
460
461        info!(
462            "Fetch completed successfully for branch {} from remote: {}",
463            branch, self.config.remote_name
464        );
465        Ok(())
466    }
467
468    /// Fetch from remote
469    pub fn fetch(&self) -> Result<()> {
470        let current_branch = self.get_current_branch()?;
471        self.fetch_branch(&current_branch)?;
472
473        // If we're on a fallback branch and have a target branch, also fetch that
474        if self.config.conflict_branch {
475            if let Ok(target) = self.get_target_branch() {
476                if target != current_branch {
477                    // Ignore errors fetching target - it might not be necessary
478                    let _ = self.fetch_branch(&target);
479                }
480            }
481        }
482
483        Ok(())
484    }
485
486    /// Push to remote
487    pub fn push(&self) -> Result<()> {
488        info!("Pushing to remote: {}", self.config.remote_name);
489
490        let current_branch = self.get_current_branch()?;
491        let refspec = format!("{}:{}", current_branch, current_branch);
492        self.transport
493            .push_refspec(&self._repo_path, &self.config.remote_name, &refspec)?;
494
495        info!(
496            "Push completed successfully to remote: {}",
497            self.config.remote_name
498        );
499        Ok(())
500    }
501
502    /// Perform a fast-forward merge
503    pub fn fast_forward_merge(&self) -> Result<()> {
504        info!("Performing fast-forward merge");
505
506        let branch_name = self.get_current_branch()?;
507        let local_branch = self.repo.find_branch(&branch_name, BranchType::Local)?;
508        let upstream = local_branch.upstream()?;
509
510        let upstream_oid = upstream
511            .get()
512            .target()
513            .ok_or_else(|| SyncError::Other("Could not get upstream OID".to_string()))?;
514
515        // Fast-forward by moving the reference
516        let mut reference = self.repo.head()?;
517        reference.set_target(upstream_oid, "fast-forward merge")?;
518
519        // Checkout the new HEAD to update working directory
520        let object = self.repo.find_object(upstream_oid, None)?;
521        let mut checkout_builder = git2::build::CheckoutBuilder::new();
522        checkout_builder.force(); // Force update working directory files
523        self.repo
524            .checkout_tree(&object, Some(&mut checkout_builder))?;
525
526        // Update HEAD to point to the new commit
527        self.repo.set_head(&format!("refs/heads/{}", branch_name))?;
528
529        info!("Fast-forward merge completed - working tree updated");
530        Ok(())
531    }
532
533    /// Perform a rebase
534    pub fn rebase(&self) -> Result<()> {
535        info!("Performing rebase");
536
537        let branch_name = self.get_current_branch()?;
538        let local_branch = self.repo.find_branch(&branch_name, BranchType::Local)?;
539        let upstream = local_branch.upstream()?;
540
541        let upstream_commit = upstream.get().peel_to_commit()?;
542        let local_commit = local_branch.get().peel_to_commit()?;
543
544        // Find merge base
545        let merge_base = self
546            .repo
547            .merge_base(local_commit.id(), upstream_commit.id())?;
548        let _merge_base_commit = self.repo.find_commit(merge_base)?;
549
550        // Create signature
551        let sig = self.repo.signature()?;
552
553        // Get annotated commits from references
554        let local_annotated = self
555            .repo
556            .reference_to_annotated_commit(local_branch.get())?;
557        let upstream_annotated = self.repo.reference_to_annotated_commit(upstream.get())?;
558
559        // Start rebase
560        let mut rebase = self.repo.rebase(
561            Some(&local_annotated),
562            Some(&upstream_annotated),
563            None,
564            None,
565        )?;
566
567        // Process each commit
568        while let Some(operation) = rebase.next() {
569            let _operation = operation?;
570
571            // Check if we can continue
572            if self.repo.index()?.has_conflicts() {
573                warn!("Conflicts detected during rebase");
574                rebase.abort()?;
575
576                // If conflict_branch is enabled, create a fallback branch
577                if self.config.conflict_branch {
578                    return self.handle_conflict_with_fallback();
579                }
580
581                return Err(SyncError::ManualInterventionRequired {
582                    reason: "Rebase conflicts detected. Please resolve manually.".to_string(),
583                });
584            }
585
586            // Continue with the rebase
587            rebase.commit(None, &sig, None)?;
588        }
589
590        // Finish the rebase
591        rebase.finish(Some(&sig))?;
592
593        // Ensure working tree is properly updated after rebase
594        let head = self.repo.head()?;
595        let head_commit = head.peel_to_commit()?;
596        let mut checkout_builder = git2::build::CheckoutBuilder::new();
597        checkout_builder.force();
598        self.repo
599            .checkout_tree(head_commit.as_object(), Some(&mut checkout_builder))?;
600
601        info!("Rebase completed successfully - working tree updated");
602        Ok(())
603    }
604
605    /// Detect the repository's default branch
606    pub fn detect_default_branch(&self) -> Result<String> {
607        // Try to get the default branch from origin/HEAD
608        if let Ok(reference) = self.repo.find_reference("refs/remotes/origin/HEAD") {
609            if let Ok(resolved) = reference.resolve() {
610                if let Some(name) = resolved.shorthand() {
611                    // name will be like "origin/main", extract just "main"
612                    if let Some(branch) = name.strip_prefix("origin/") {
613                        debug!("Detected default branch from origin/HEAD: {}", branch);
614                        return Ok(branch.to_string());
615                    }
616                }
617            }
618        }
619
620        // Fallback: check if main or master exists
621        if self.repo.find_branch("main", BranchType::Local).is_ok()
622            || self.repo.find_reference("refs/remotes/origin/main").is_ok()
623        {
624            debug!("Falling back to 'main' as default branch");
625            return Ok("main".to_string());
626        }
627
628        if self.repo.find_branch("master", BranchType::Local).is_ok()
629            || self
630                .repo
631                .find_reference("refs/remotes/origin/master")
632                .is_ok()
633        {
634            debug!("Falling back to 'master' as default branch");
635            return Ok("master".to_string());
636        }
637
638        // Last resort: use current branch
639        self.get_current_branch()
640    }
641
642    /// Get the target branch (the branch we want to be on)
643    pub fn get_target_branch(&self) -> Result<String> {
644        if let Some(ref target) = self.config.target_branch {
645            if !target.is_empty() {
646                return Ok(target.clone());
647            }
648        }
649        self.detect_default_branch()
650    }
651
652    /// Check if we're currently on a fallback branch
653    pub fn is_on_fallback_branch(&self) -> Result<bool> {
654        let current = self.get_current_branch()?;
655        Ok(current.starts_with(FALLBACK_BRANCH_PREFIX))
656    }
657
658    /// Generate a fallback branch name
659    fn generate_fallback_branch_name() -> String {
660        let hostname = hostname::get()
661            .map(|h| h.to_string_lossy().to_string())
662            .unwrap_or_else(|_| "unknown".to_string());
663        let timestamp = Local::now().format("%Y-%m-%d-%H%M%S");
664        format!("{}{}-{}", FALLBACK_BRANCH_PREFIX, hostname, timestamp)
665    }
666
667    /// Create and switch to a fallback branch
668    pub fn create_fallback_branch(&self) -> Result<String> {
669        let branch_name = Self::generate_fallback_branch_name();
670        info!("Creating fallback branch: {}", branch_name);
671
672        // Get current HEAD commit
673        let head_commit = self.repo.head()?.peel_to_commit()?;
674
675        // Create the new branch
676        self.repo
677            .branch(&branch_name, &head_commit, false)
678            .map_err(|e| SyncError::Other(format!("Failed to create fallback branch: {}", e)))?;
679
680        // Checkout the new branch
681        let refname = format!("refs/heads/{}", branch_name);
682        self.repo.set_head(&refname)?;
683
684        // Update working directory
685        let mut checkout_builder = git2::build::CheckoutBuilder::new();
686        checkout_builder.force();
687        self.repo
688            .checkout_head(Some(&mut checkout_builder))
689            .map_err(|e| SyncError::Other(format!("Failed to checkout fallback branch: {}", e)))?;
690
691        info!("Switched to fallback branch: {}", branch_name);
692        Ok(branch_name)
693    }
694
695    /// Push a branch to remote (used for fallback branches)
696    pub fn push_branch(&self, branch_name: &str) -> Result<()> {
697        info!("Pushing branch {} to remote", branch_name);
698
699        self.transport.push_branch_upstream(
700            &self._repo_path,
701            &self.config.remote_name,
702            branch_name,
703        )?;
704
705        info!("Successfully pushed branch {} to remote", branch_name);
706        Ok(())
707    }
708
709    /// Check if merging target branch into current HEAD would succeed (in-memory, no working tree changes)
710    pub fn can_merge_cleanly(&self, target_branch: &str) -> Result<bool> {
711        // Get the target branch reference
712        let target_ref = format!("refs/remotes/{}/{}", self.config.remote_name, target_branch);
713        let target_reference = self.repo.find_reference(&target_ref).map_err(|e| {
714            SyncError::Other(format!(
715                "Failed to find target branch {}: {}",
716                target_branch, e
717            ))
718        })?;
719        let target_commit = target_reference.peel_to_commit()?;
720
721        // Get current HEAD
722        let head_commit = self.repo.head()?.peel_to_commit()?;
723
724        // Check if we're already ancestors (fast-forward possible)
725        if self
726            .repo
727            .graph_descendant_of(target_commit.id(), head_commit.id())?
728        {
729            debug!(
730                "Target branch {} is descendant of current HEAD, clean merge possible",
731                target_branch
732            );
733            return Ok(true);
734        }
735
736        // Perform in-memory merge to check for conflicts
737        let merge_opts = MergeOptions::new();
738        let index = self
739            .repo
740            .merge_commits(&head_commit, &target_commit, Some(&merge_opts))
741            .map_err(|e| SyncError::Other(format!("Failed to perform merge check: {}", e)))?;
742
743        let has_conflicts = index.has_conflicts();
744        debug!("In-memory merge check: has_conflicts={}", has_conflicts);
745
746        Ok(!has_conflicts)
747    }
748
749    /// Get the OID of the target branch on remote
750    fn get_target_branch_oid(&self, target_branch: &str) -> Result<Oid> {
751        let target_ref = format!("refs/remotes/{}/{}", self.config.remote_name, target_branch);
752        let reference = self.repo.find_reference(&target_ref)?;
753        reference
754            .target()
755            .ok_or_else(|| SyncError::Other("Target branch has no OID".to_string()))
756    }
757
758    /// Attempt to return to the target branch from a fallback branch
759    pub fn try_return_to_target(&mut self) -> Result<bool> {
760        if !self.is_on_fallback_branch()? {
761            return Ok(false);
762        }
763
764        let target_branch = self.get_target_branch()?;
765        info!(
766            "On fallback branch, checking if we can return to {}",
767            target_branch
768        );
769
770        // Get current target branch OID
771        let target_oid = match self.get_target_branch_oid(&target_branch) {
772            Ok(oid) => oid,
773            Err(e) => {
774                warn!("Could not find target branch {}: {}", target_branch, e);
775                return Ok(false);
776            }
777        };
778
779        // Check if target has moved since last check
780        if let Some(last_checked) = self.fallback_state.last_checked_target_oid {
781            if last_checked == target_oid {
782                debug!(
783                    "Target branch {} hasn't changed since last check, skipping merge check",
784                    target_branch
785                );
786                return Ok(false);
787            }
788        }
789
790        // Target has moved, check if we can merge cleanly
791        if !self.can_merge_cleanly(&target_branch)? {
792            info!(
793                "Cannot cleanly merge {} into current branch, staying on fallback",
794                target_branch
795            );
796            self.fallback_state.last_checked_target_oid = Some(target_oid);
797            return Ok(false);
798        }
799
800        info!(
801            "Clean merge possible, returning to target branch {}",
802            target_branch
803        );
804
805        // Get current branch commits that need to be rebased onto target
806        let current_branch = self.get_current_branch()?;
807        let current_oid = self
808            .repo
809            .head()?
810            .target()
811            .ok_or_else(|| SyncError::Other("Current HEAD has no OID".to_string()))?;
812
813        // Find merge base between our fallback branch and target
814        let merge_base = self.repo.merge_base(current_oid, target_oid)?;
815
816        // Check if we have commits to rebase
817        let (ahead, _) = self.repo.graph_ahead_behind(current_oid, merge_base)?;
818        let has_commits_to_rebase = ahead > 0;
819
820        // Checkout target branch
821        let target_ref = format!("refs/heads/{}", target_branch);
822
823        // First, make sure local target branch exists and is up to date
824        let remote_target_ref =
825            format!("refs/remotes/{}/{}", self.config.remote_name, target_branch);
826        let remote_target = self.repo.find_reference(&remote_target_ref)?;
827        let remote_target_oid = remote_target
828            .target()
829            .ok_or_else(|| SyncError::Other("Remote target has no OID".to_string()))?;
830
831        // Update or create local target branch
832        if self.repo.find_reference(&target_ref).is_ok() {
833            // Update existing branch
834            self.repo.reference(
835                &target_ref,
836                remote_target_oid,
837                true,
838                "git-sync: updating target branch before return",
839            )?;
840        } else {
841            // Create local tracking branch
842            let remote_commit = self.repo.find_commit(remote_target_oid)?;
843            self.repo.branch(&target_branch, &remote_commit, false)?;
844        }
845
846        // Checkout target branch
847        self.repo.set_head(&target_ref)?;
848        let mut checkout_builder = git2::build::CheckoutBuilder::new();
849        checkout_builder.force();
850        self.repo.checkout_head(Some(&mut checkout_builder))?;
851
852        if has_commits_to_rebase {
853            info!(
854                "Rebasing {} commits from {} onto {}",
855                ahead, current_branch, target_branch
856            );
857
858            // We need to rebase our commits from the fallback branch onto target
859            // Get the commits from the fallback branch
860            let fallback_ref = format!("refs/heads/{}", current_branch);
861            let fallback_reference = self.repo.find_reference(&fallback_ref)?;
862            let fallback_annotated = self
863                .repo
864                .reference_to_annotated_commit(&fallback_reference)?;
865
866            let target_reference = self.repo.find_reference(&target_ref)?;
867            let target_annotated = self.repo.reference_to_annotated_commit(&target_reference)?;
868
869            let sig = self.repo.signature()?;
870
871            // Start rebase
872            let mut rebase = self.repo.rebase(
873                Some(&fallback_annotated),
874                Some(&target_annotated),
875                None,
876                None,
877            )?;
878
879            // Process each commit
880            while let Some(operation) = rebase.next() {
881                let _operation = operation?;
882
883                if self.repo.index()?.has_conflicts() {
884                    warn!("Conflicts during rebase back to target, aborting");
885                    rebase.abort()?;
886                    // Switch back to fallback branch
887                    self.repo.set_head(&fallback_ref)?;
888                    self.repo.checkout_head(Some(&mut checkout_builder))?;
889                    self.fallback_state.last_checked_target_oid = Some(target_oid);
890                    return Ok(false);
891                }
892
893                rebase.commit(None, &sig, None)?;
894            }
895
896            rebase.finish(Some(&sig))?;
897
898            // Update working tree
899            let head = self.repo.head()?;
900            let head_commit = head.peel_to_commit()?;
901            self.repo
902                .checkout_tree(head_commit.as_object(), Some(&mut checkout_builder))?;
903        }
904
905        // Clear fallback state
906        self.fallback_state.last_checked_target_oid = None;
907
908        info!("Successfully returned to target branch {}", target_branch);
909        Ok(true)
910    }
911
912    /// Handle a rebase conflict by creating a fallback branch (when conflict_branch is enabled)
913    fn handle_conflict_with_fallback(&self) -> Result<()> {
914        if !self.config.conflict_branch {
915            return Err(SyncError::ManualInterventionRequired {
916                reason: "Rebase conflicts detected. Please resolve manually.".to_string(),
917            });
918        }
919
920        info!("Conflict detected with conflict_branch enabled, creating fallback branch");
921
922        // Create fallback branch from current state
923        let fallback_branch = self.create_fallback_branch()?;
924
925        // Commit any uncommitted changes on the fallback branch
926        if self.has_local_changes()? {
927            self.auto_commit()?;
928        }
929
930        // Push the fallback branch
931        self.push_branch(&fallback_branch)?;
932
933        info!(
934            "Switched to fallback branch {} due to conflicts. \
935             Will automatically return to target branch when conflicts are resolved.",
936            fallback_branch
937        );
938
939        Ok(())
940    }
941
942    /// Main sync operation
943    pub fn sync(&mut self, check_only: bool) -> Result<()> {
944        info!("Starting sync operation (check_only: {})", check_only);
945
946        // Check repository state
947        let repo_state = self.get_repository_state()?;
948        match repo_state {
949            RepositoryState::Clean | RepositoryState::Dirty => {
950                // These states are OK to continue
951            }
952            RepositoryState::DetachedHead => {
953                return Err(SyncError::DetachedHead);
954            }
955            _ => {
956                return Err(SyncError::UnsafeRepositoryState {
957                    state: format!("{:?}", repo_state),
958                });
959            }
960        }
961
962        // Check for unhandled files
963        if let Some(unhandled) = self.check_unhandled_files()? {
964            let reason = match unhandled {
965                UnhandledFileState::Conflicted { path } => format!("Conflicted file: {}", path),
966            };
967            return Err(SyncError::ManualInterventionRequired { reason });
968        }
969
970        // If we're only checking, we're done
971        if check_only {
972            info!("Check passed, sync can proceed");
973            return Ok(());
974        }
975
976        // Bootstrap flow for freshly cloned empty repositories.
977        // In this state HEAD points to a branch name but has no commit yet.
978        if self.is_head_unborn()? {
979            info!("Repository HEAD is unborn; attempting initial publish");
980            if self.has_local_changes()? {
981                self.auto_commit()?;
982                let branch = self.get_current_branch()?;
983                self.push_branch(&branch)?;
984            } else {
985                info!("HEAD is unborn and there are no local changes to publish");
986            }
987            return Ok(());
988        }
989
990        // Fetch from remote first (needed for both normal sync and return-to-target check)
991        self.fetch()?;
992
993        // If we're on a fallback branch and conflict_branch is enabled,
994        // try to return to the target branch
995        if self.config.conflict_branch
996            && self.is_on_fallback_branch()?
997            && self.try_return_to_target()?
998        {
999            // Successfully returned to target, update branch name for sync state check
1000            info!("Returned to target branch, continuing with normal sync");
1001        }
1002
1003        // Auto-commit if there are local changes
1004        if self.has_local_changes()? {
1005            self.auto_commit()?;
1006        }
1007
1008        // Get sync state and handle accordingly
1009        let sync_state = self.get_sync_state()?;
1010        match sync_state {
1011            SyncState::Equal => {
1012                info!("Already in sync");
1013            }
1014            SyncState::Ahead(_) => {
1015                info!("Local is ahead, pushing");
1016                self.push()?;
1017            }
1018            SyncState::Behind(_) => {
1019                info!("Local is behind, fast-forwarding");
1020                self.fast_forward_merge()?;
1021            }
1022            SyncState::Diverged { .. } => {
1023                info!("Branches have diverged, rebasing");
1024                self.rebase()?;
1025                self.push()?;
1026            }
1027            SyncState::NoUpstream => {
1028                // If we're on a fallback branch that doesn't have upstream yet, push it
1029                if self.is_on_fallback_branch()? {
1030                    info!("Fallback branch has no upstream, pushing");
1031                    let branch = self.get_current_branch()?;
1032                    self.push_branch(&branch)?;
1033                } else {
1034                    let branch = self
1035                        .get_current_branch()
1036                        .unwrap_or_else(|_| "<unknown>".into());
1037                    return Err(SyncError::NoRemoteConfigured { branch });
1038                }
1039            }
1040        }
1041
1042        // Verify we're in sync (skip for fallback branches that may not have upstream yet)
1043        let final_state = self.get_sync_state()?;
1044        if final_state != SyncState::Equal && final_state != SyncState::NoUpstream {
1045            warn!(
1046                "Sync completed but repository is not in sync: {:?}",
1047                final_state
1048            );
1049            return Err(SyncError::Other(
1050                "Sync completed but repository is not in sync".to_string(),
1051            ));
1052        }
1053
1054        info!("Sync completed successfully");
1055        Ok(())
1056    }
1057
1058    /// Returns true when HEAD points at an unborn branch (no commit yet).
1059    fn is_head_unborn(&self) -> Result<bool> {
1060        match self.repo.head() {
1061            Ok(head) => match head.peel_to_commit() {
1062                Ok(_) => Ok(false),
1063                Err(e) if e.code() == git2::ErrorCode::UnbornBranch => Ok(true),
1064                Err(e) => Err(e.into()),
1065            },
1066            Err(e) if e.code() == git2::ErrorCode::UnbornBranch => Ok(true),
1067            Err(e) => Err(e.into()),
1068        }
1069    }
1070
1071    fn unborn_head_branch_name(&self) -> Result<Option<String>> {
1072        let head_path = self.repo.path().join("HEAD");
1073        let head_contents = fs::read_to_string(head_path)?;
1074        Ok(head_contents
1075            .trim()
1076            .strip_prefix("ref: refs/heads/")
1077            .map(|s| s.to_string()))
1078    }
1079}