Skip to main content

rung_git/
repository.rs

1//! Repository wrapper providing high-level git operations.
2
3use std::path::Path;
4
5use git2::{BranchType, Oid, RepositoryState, Signature};
6
7use crate::error::{Error, Result};
8use crate::traits::GitOps;
9
10/// Predicted conflict for a single commit during a rebase operation.
11///
12/// This is used by the conflict prediction system to warn users about
13/// potential conflicts before starting a sync operation.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ConflictPrediction {
16    /// The commit that would cause conflicts.
17    pub commit: Oid,
18    /// The commit message (first line).
19    pub commit_summary: String,
20    /// Files that would conflict when applying this commit.
21    pub conflicting_files: Vec<String>,
22}
23
24/// Divergence state between a local branch and its tracking remote (upstream, falls back to origin).
25///
26/// This is distinct from `BranchState::Diverged` which tracks divergence from the
27/// *parent branch* (needs sync). `RemoteDivergence` tracks local vs remote (needs push/pull).
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum RemoteDivergence {
30    /// Local and remote are at the same commit.
31    InSync,
32    /// Local has commits not on remote (safe push).
33    Ahead {
34        /// Number of commits local is ahead of remote.
35        commits: usize,
36    },
37    /// Remote has commits not on local (need pull).
38    Behind {
39        /// Number of commits local is behind remote.
40        commits: usize,
41    },
42    /// Both have unique commits (need force push after rebase).
43    Diverged {
44        /// Number of commits local is ahead of remote.
45        ahead: usize,
46        /// Number of commits local is behind remote.
47        behind: usize,
48    },
49    /// No remote tracking branch exists (first push).
50    NoRemote,
51}
52
53/// High-level wrapper around a git repository.
54pub struct Repository {
55    inner: git2::Repository,
56}
57
58impl Repository {
59    /// Open a repository at the given path.
60    ///
61    /// # Errors
62    /// Returns error if no repository found at path or any parent.
63    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
64        let inner = git2::Repository::discover(path)?;
65        Ok(Self { inner })
66    }
67
68    /// Open the repository containing the current directory.
69    ///
70    /// # Errors
71    /// Returns error if not inside a git repository.
72    pub fn open_current() -> Result<Self> {
73        Self::open(".")
74    }
75
76    /// Get the path to the repository root (workdir).
77    #[must_use]
78    pub fn workdir(&self) -> Option<&Path> {
79        self.inner.workdir()
80    }
81
82    /// Get the path to the .git directory.
83    #[must_use]
84    pub fn git_dir(&self) -> &Path {
85        self.inner.path()
86    }
87
88    /// Get the current repository state.
89    #[must_use]
90    pub fn state(&self) -> RepositoryState {
91        self.inner.state()
92    }
93
94    /// Check if there's a rebase in progress.
95    #[must_use]
96    pub fn is_rebasing(&self) -> bool {
97        matches!(
98            self.state(),
99            RepositoryState::Rebase
100                | RepositoryState::RebaseInteractive
101                | RepositoryState::RebaseMerge
102        )
103    }
104
105    /// Check if HEAD is detached (not pointing at a branch).
106    ///
107    /// # Errors
108    /// Returns error if HEAD cannot be read (e.g. unborn repo).
109    pub fn head_detached(&self) -> Result<bool> {
110        let head = self.inner.head()?;
111        Ok(!head.is_branch())
112    }
113
114    // === Branch operations ===
115
116    /// Get the name of the current branch.
117    ///
118    /// # Errors
119    /// Returns error if HEAD is detached.
120    pub fn current_branch(&self) -> Result<String> {
121        let head = self.inner.head()?;
122        if !head.is_branch() {
123            return Err(Error::DetachedHead);
124        }
125
126        head.shorthand()
127            .map(String::from)
128            .ok_or(Error::DetachedHead)
129    }
130
131    /// Get the commit SHA for a branch.
132    ///
133    /// # Errors
134    /// Returns error if branch doesn't exist.
135    pub fn branch_commit(&self, branch_name: &str) -> Result<Oid> {
136        let branch = self
137            .inner
138            .find_branch(branch_name, BranchType::Local)
139            .map_err(|_| Error::BranchNotFound(branch_name.into()))?;
140
141        branch
142            .get()
143            .target()
144            .ok_or_else(|| Error::BranchNotFound(branch_name.into()))
145    }
146
147    /// Get the commit ID of a remote branch tip.
148    ///
149    /// Uses the configured upstream if set, otherwise falls back to `origin/<branch>`.
150    ///
151    /// # Errors
152    /// Returns error if branch not found.
153    pub fn remote_branch_commit(&self, branch_name: &str) -> Result<Oid> {
154        // Try configured upstream first, fall back to origin/<branch>
155        let remote_ref = self
156            .branch_upstream_ref(branch_name)
157            .unwrap_or_else(|| format!("refs/remotes/origin/{branch_name}"));
158
159        let reference = self
160            .inner
161            .find_reference(&remote_ref)
162            .map_err(|_| Error::BranchNotFound(remote_ref.clone()))?;
163
164        reference.target().ok_or(Error::BranchNotFound(remote_ref))
165    }
166
167    /// Get the configured upstream ref for a branch, if any.
168    ///
169    /// Returns `None` if no upstream is configured or the branch doesn't exist.
170    /// Uses `branch_upstream_name` to read from git config, which works even when
171    /// the remote-tracking ref doesn't exist locally.
172    fn branch_upstream_ref(&self, branch_name: &str) -> Option<String> {
173        let refname = format!("refs/heads/{branch_name}");
174        let upstream_buf = self.inner.branch_upstream_name(&refname).ok()?;
175        upstream_buf.as_str().map(String::from)
176    }
177
178    /// Create a new branch at the current HEAD.
179    ///
180    /// # Errors
181    /// Returns error if branch creation fails.
182    pub fn create_branch(&self, name: &str) -> Result<Oid> {
183        let head_commit = self.inner.head()?.peel_to_commit()?;
184        let branch = self.inner.branch(name, &head_commit, false)?;
185
186        branch
187            .get()
188            .target()
189            .ok_or_else(|| Error::BranchNotFound(name.into()))
190    }
191
192    /// Checkout a branch.
193    ///
194    /// # Errors
195    /// Returns error if checkout fails.
196    pub fn checkout(&self, branch_name: &str) -> Result<()> {
197        let branch = self
198            .inner
199            .find_branch(branch_name, BranchType::Local)
200            .map_err(|_| Error::BranchNotFound(branch_name.into()))?;
201
202        let reference = branch.get();
203        let object = reference.peel(git2::ObjectType::Commit)?;
204
205        self.inner.checkout_tree(&object, None)?;
206        self.inner.set_head(&format!("refs/heads/{branch_name}"))?;
207
208        Ok(())
209    }
210
211    /// List all local branches.
212    ///
213    /// # Errors
214    /// Returns error if branch listing fails.
215    pub fn list_branches(&self) -> Result<Vec<String>> {
216        let branches = self.inner.branches(Some(BranchType::Local))?;
217
218        let names: Vec<String> = branches
219            .filter_map(std::result::Result::ok)
220            .filter_map(|(b, _)| b.name().ok().flatten().map(String::from))
221            .collect();
222
223        Ok(names)
224    }
225
226    /// Check if a branch exists.
227    #[must_use]
228    pub fn branch_exists(&self, name: &str) -> bool {
229        self.inner.find_branch(name, BranchType::Local).is_ok()
230    }
231
232    /// Delete a local branch.
233    ///
234    /// # Errors
235    /// Returns error if branch deletion fails.
236    pub fn delete_branch(&self, name: &str) -> Result<()> {
237        let mut branch = self.inner.find_branch(name, BranchType::Local)?;
238        branch.delete()?;
239        Ok(())
240    }
241
242    // === Working directory state ===
243
244    /// Check if the working directory is clean (no modified or staged files).
245    ///
246    /// Untracked files are ignored - only tracked files that have been
247    /// modified or staged count as "dirty".
248    ///
249    /// # Errors
250    /// Returns error if status check fails.
251    pub fn is_clean(&self) -> Result<bool> {
252        let mut opts = git2::StatusOptions::new();
253        opts.include_untracked(false)
254            .include_ignored(false)
255            .include_unmodified(false)
256            .exclude_submodules(true);
257        let statuses = self.inner.statuses(Some(&mut opts))?;
258
259        // Check if any status indicates modified/staged files
260        for entry in statuses.iter() {
261            let status = entry.status();
262            // These indicate actual changes to tracked files
263            if status.intersects(
264                git2::Status::INDEX_NEW
265                    | git2::Status::INDEX_MODIFIED
266                    | git2::Status::INDEX_DELETED
267                    | git2::Status::INDEX_RENAMED
268                    | git2::Status::INDEX_TYPECHANGE
269                    | git2::Status::WT_MODIFIED
270                    | git2::Status::WT_DELETED
271                    | git2::Status::WT_TYPECHANGE
272                    | git2::Status::WT_RENAMED,
273            ) {
274                return Ok(false);
275            }
276        }
277        Ok(true)
278    }
279
280    /// Ensure working directory is clean, returning error if not.
281    ///
282    /// # Errors
283    /// Returns `DirtyWorkingDirectory` if there are uncommitted changes.
284    pub fn require_clean(&self) -> Result<()> {
285        if self.is_clean()? {
286            Ok(())
287        } else {
288            Err(Error::DirtyWorkingDirectory)
289        }
290    }
291
292    // === Staging operations ===
293
294    /// Stage all changes (tracked and untracked files).
295    ///
296    /// Equivalent to `git add -A`.
297    ///
298    /// # Errors
299    /// Returns error if staging fails.
300    pub fn stage_all(&self) -> Result<()> {
301        let workdir = self.workdir().ok_or(Error::NotARepository)?;
302
303        let output = std::process::Command::new("git")
304            .args(["add", "-A"])
305            .current_dir(workdir)
306            .output()
307            .map_err(|e| Error::Git2(git2::Error::from_str(&e.to_string())))?;
308
309        if output.status.success() {
310            Ok(())
311        } else {
312            let stderr = String::from_utf8_lossy(&output.stderr);
313            Err(Error::Git2(git2::Error::from_str(&stderr)))
314        }
315    }
316
317    /// Check if there are staged changes ready to commit.
318    ///
319    /// # Errors
320    /// Returns error if status check fails.
321    pub fn has_staged_changes(&self) -> Result<bool> {
322        let mut opts = git2::StatusOptions::new();
323        opts.include_untracked(false)
324            .include_ignored(false)
325            .include_unmodified(false);
326        let statuses = self.inner.statuses(Some(&mut opts))?;
327
328        for entry in statuses.iter() {
329            let status = entry.status();
330            if status.intersects(
331                git2::Status::INDEX_NEW
332                    | git2::Status::INDEX_MODIFIED
333                    | git2::Status::INDEX_DELETED
334                    | git2::Status::INDEX_RENAMED
335                    | git2::Status::INDEX_TYPECHANGE,
336            ) {
337                return Ok(true);
338            }
339        }
340        Ok(false)
341    }
342
343    /// Create a commit with the given message on HEAD.
344    ///
345    /// Handles both normal commits (with parent) and initial commits (no parent).
346    ///
347    /// # Errors
348    /// Returns error if commit creation fails.
349    pub fn create_commit(&self, message: &str) -> Result<Oid> {
350        let sig = self.signature()?;
351        let mut index = self.inner.index()?;
352        // Reload index from disk in case it was modified by external commands (e.g., git add)
353        index.read(false)?;
354        let tree_id = index.write_tree()?;
355        let tree = self.inner.find_tree(tree_id)?;
356
357        // Handle initial commit case (unborn HEAD)
358        let oid = match self.inner.head().and_then(|h| h.peel_to_commit()) {
359            Ok(parent) => {
360                self.inner
361                    .commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])?
362            }
363            Err(_) => {
364                // Initial commit - no parent
365                self.inner
366                    .commit(Some("HEAD"), &sig, &sig, message, &tree, &[])?
367            }
368        };
369
370        Ok(oid)
371    }
372
373    /// Amend the last commit with staged changes.
374    ///
375    /// Equivalent to `git commit --amend --no-edit` or
376    /// `git commit --amend -m "message"` if a new message is provided.
377    ///
378    /// # Errors
379    /// Returns error if amend fails or no commits exist.
380    pub fn amend_commit(&self, new_message: Option<&str>) -> Result<Oid> {
381        let workdir = self.workdir().ok_or(Error::NotARepository)?;
382
383        let mut args = vec!["commit", "--amend"];
384
385        match new_message {
386            Some(msg) => args.extend(["-m", msg]),
387            None => args.push("--no-edit"),
388        }
389
390        let output = std::process::Command::new("git")
391            .args(&args)
392            .current_dir(workdir)
393            .output()
394            .map_err(|e| Error::Git2(git2::Error::from_str(&e.to_string())))?;
395
396        if output.status.success() {
397            // Return the new HEAD commit directly (works even on detached HEAD)
398            let head = self.inner.head()?;
399            Ok(head.peel_to_commit()?.id())
400        } else {
401            let stderr = String::from_utf8_lossy(&output.stderr);
402            Err(Error::Git2(git2::Error::from_str(&stderr)))
403        }
404    }
405
406    // === Commit operations ===
407
408    /// Get a commit by its SHA.
409    ///
410    /// # Errors
411    /// Returns error if commit not found.
412    pub fn find_commit(&self, oid: Oid) -> Result<git2::Commit<'_>> {
413        Ok(self.inner.find_commit(oid)?)
414    }
415
416    /// Get the commit message from a branch's tip commit.
417    ///
418    /// # Errors
419    /// Returns error if branch doesn't exist or has no commits.
420    pub fn branch_commit_message(&self, branch_name: &str) -> Result<String> {
421        let oid = self.branch_commit(branch_name)?;
422        let commit = self.inner.find_commit(oid)?;
423        commit
424            .message()
425            .map(String::from)
426            .ok_or_else(|| Error::Git2(git2::Error::from_str("commit has no message")))
427    }
428
429    /// Get the merge base between two commits.
430    ///
431    /// # Errors
432    /// Returns error if merge base calculation fails.
433    pub fn merge_base(&self, one: Oid, two: Oid) -> Result<Oid> {
434        Ok(self.inner.merge_base(one, two)?)
435    }
436
437    /// Count commits between two points.
438    ///
439    /// # Errors
440    /// Returns error if revwalk fails.
441    pub fn count_commits_between(&self, from: Oid, to: Oid) -> Result<usize> {
442        let mut revwalk = self.inner.revwalk()?;
443        revwalk.push(to)?;
444        revwalk.hide(from)?;
445
446        Ok(revwalk.count())
447    }
448
449    /// Get commits between two points.
450    ///
451    /// # Errors
452    /// Return error if revwalk fails.
453    pub fn commits_between(&self, from: Oid, to: Oid) -> Result<Vec<Oid>> {
454        let mut revwalk = self.inner.revwalk()?;
455        revwalk.push(to)?;
456        revwalk.hide(from)?;
457
458        let mut commits = Vec::new();
459        for oid in revwalk {
460            let oid = oid?;
461            commits.push(oid);
462        }
463
464        Ok(commits)
465    }
466
467    // === Reset operations ===
468
469    /// Hard reset a branch to a specific commit.
470    ///
471    /// # Errors
472    /// Returns error if reset fails.
473    pub fn reset_branch(&self, branch_name: &str, target: Oid) -> Result<()> {
474        let commit = self.inner.find_commit(target)?;
475        let reference_name = format!("refs/heads/{branch_name}");
476
477        let target_str = target.to_string();
478        let short_sha = target_str.get(..8).unwrap_or(&target_str);
479        self.inner.reference(
480            &reference_name,
481            target,
482            true, // force
483            &format!("rung: reset to {short_sha}"),
484        )?;
485
486        // If this is the current branch, also update working directory
487        if self.current_branch().ok().as_deref() == Some(branch_name) {
488            self.inner
489                .reset(commit.as_object(), git2::ResetType::Hard, None)?;
490        }
491
492        Ok(())
493    }
494
495    // === Signature ===
496
497    /// Get the default signature for commits.
498    ///
499    /// # Errors
500    /// Returns error if git config doesn't have user.name/email.
501    pub fn signature(&self) -> Result<Signature<'_>> {
502        Ok(self.inner.signature()?)
503    }
504
505    // === Rebase operations ===
506
507    /// Rebase the current branch onto a target commit.
508    ///
509    /// Returns `Ok(())` on success, or `Err(RebaseConflict)` if there are conflicts.
510    ///
511    /// # Errors
512    /// Returns error if rebase fails or conflicts occur.
513    pub fn rebase_onto(&self, target: Oid) -> Result<()> {
514        let workdir = self.workdir().ok_or(Error::NotARepository)?;
515
516        let output = std::process::Command::new("git")
517            .args(["rebase", &target.to_string()])
518            .current_dir(workdir)
519            .output()
520            .map_err(|e| Error::RebaseFailed(e.to_string()))?;
521
522        if output.status.success() {
523            return Ok(());
524        }
525
526        // Check if it's a conflict
527        if self.is_rebasing() {
528            let conflicts = self.conflicting_files()?;
529            return Err(Error::RebaseConflict(conflicts));
530        }
531
532        let stderr = String::from_utf8_lossy(&output.stderr);
533        Err(Error::RebaseFailed(stderr.to_string()))
534    }
535
536    /// Rebase the current branch onto a new base, replaying only commits after `old_base`.
537    ///
538    /// This is equivalent to `git rebase --onto <new_base> <old_base>`.
539    /// Use this when the `old_base` was squash-merged and you want to bring only
540    /// the unique commits from the current branch.
541    ///
542    /// # Errors
543    /// Returns error if rebase fails or conflicts occur.
544    pub fn rebase_onto_from(&self, new_base: Oid, old_base: Oid) -> Result<()> {
545        let workdir = self.workdir().ok_or(Error::NotARepository)?;
546
547        let output = std::process::Command::new("git")
548            .args([
549                "rebase",
550                "--onto",
551                &new_base.to_string(),
552                &old_base.to_string(),
553            ])
554            .current_dir(workdir)
555            .output()
556            .map_err(|e| Error::RebaseFailed(e.to_string()))?;
557
558        if output.status.success() {
559            return Ok(());
560        }
561
562        // Check if it's a conflict
563        if self.is_rebasing() {
564            let conflicts = self.conflicting_files()?;
565            return Err(Error::RebaseConflict(conflicts));
566        }
567
568        let stderr = String::from_utf8_lossy(&output.stderr);
569        Err(Error::RebaseFailed(stderr.to_string()))
570    }
571
572    /// Get list of files with conflicts.
573    ///
574    /// # Errors
575    /// Returns error if status check fails.
576    pub fn conflicting_files(&self) -> Result<Vec<String>> {
577        let statuses = self.inner.statuses(None)?;
578        let conflicts: Vec<String> = statuses
579            .iter()
580            .filter(|s| s.status().is_conflicted())
581            .filter_map(|s| s.path().map(String::from))
582            .collect();
583        Ok(conflicts)
584    }
585
586    /// Predict conflicts that would occur when rebasing a branch onto a target.
587    ///
588    /// This simulates the rebase by using `git merge-tree` to check if each
589    /// commit would conflict when applied to the target. Unlike an actual rebase,
590    /// this does not modify any files or refs.
591    ///
592    /// # Arguments
593    /// * `branch` - The branch to check for conflicts
594    /// * `onto` - The target commit to rebase onto
595    ///
596    /// # Returns
597    /// A list of commits that would cause conflicts, along with the conflicting files.
598    /// An empty list means no conflicts are predicted.
599    ///
600    /// # Errors
601    /// Returns error if git operations fail.
602    pub fn predict_rebase_conflicts(
603        &self,
604        branch: &str,
605        onto: Oid,
606    ) -> Result<Vec<ConflictPrediction>> {
607        let workdir = self.workdir().ok_or(Error::NotARepository)?;
608        let branch_commit = self.branch_commit(branch)?;
609        let merge_base = self.merge_base(branch_commit, onto)?;
610
611        // Get commits to replay (in reverse order: oldest first)
612        let commits = self.commits_between(merge_base, branch_commit)?;
613
614        if commits.is_empty() {
615            return Ok(Vec::new());
616        }
617
618        let mut predictions = Vec::new();
619
620        // Track the current base for each simulated cherry-pick
621        let mut current_base = onto;
622
623        // Process commits oldest-first (reverse of revwalk order)
624        for commit_oid in commits.iter().rev() {
625            let commit = self.inner.find_commit(*commit_oid)?;
626
627            // Skip merge commits (they have multiple parents)
628            if commit.parent_count() != 1 {
629                continue;
630            }
631
632            let parent_oid = commit.parent_id(0)?;
633
634            // Use git merge-tree to simulate cherry-picking this commit onto current_base
635            // For cherry-pick simulation:
636            // - merge-base: parent of the commit being cherry-picked
637            // - branch1 (ours): current_base (where we're rebasing onto)
638            // - branch2 (theirs): the commit being cherry-picked
639            let output = std::process::Command::new("git")
640                .args([
641                    "merge-tree",
642                    "--write-tree",
643                    &format!("--merge-base={parent_oid}"),
644                    &current_base.to_string(),
645                    &commit_oid.to_string(),
646                ])
647                .current_dir(workdir)
648                .output()
649                .map_err(|e| Error::Git2(git2::Error::from_str(&e.to_string())))?;
650
651            // Parse the output - first line is always the resulting tree OID
652            let stdout = String::from_utf8_lossy(&output.stdout);
653            let mut lines = stdout.lines();
654
655            // Extract the tree OID from the first line for proper conflict chaining.
656            // This tree represents the result of applying this commit (with or without conflicts).
657            // We must have a valid tree OID to chain subsequent commits correctly.
658            let first_line = lines.next();
659            let result_tree = first_line.and_then(|line| Oid::from_str(line.trim()).ok());
660
661            // Fail early if we can't parse the tree OID - continuing would break conflict chaining
662            let result_tree = result_tree.ok_or_else(|| {
663                Error::Git2(git2::Error::from_str(&format!(
664                    "failed to parse merge-tree output for commit {}: expected tree OID on first line, got: {:?}",
665                    commit_oid,
666                    first_line.unwrap_or("<empty output>")
667                )))
668            })?;
669
670            // git merge-tree exits with 0 on success (no conflicts) and non-zero on conflicts
671            if !output.status.success() {
672                let mut conflicting_files = Vec::new();
673
674                // The output format includes lines like:
675                // CONFLICT (content): Merge conflict in <filename>
676                for line in lines {
677                    if let Some(rest) = line.strip_prefix("CONFLICT") {
678                        // Try to extract the filename
679                        if let Some(idx) = rest.find(" in ") {
680                            let filename = rest[idx + 4..].trim().to_string();
681                            if !conflicting_files.contains(&filename) {
682                                conflicting_files.push(filename);
683                            }
684                        }
685                    }
686                }
687
688                // If we couldn't parse specific files, note that there was a conflict
689                if conflicting_files.is_empty() {
690                    conflicting_files.push("<conflict detected>".to_string());
691                }
692
693                let summary = commit.summary().unwrap_or("").to_string();
694
695                predictions.push(ConflictPrediction {
696                    commit: *commit_oid,
697                    commit_summary: summary,
698                    conflicting_files,
699                });
700            }
701
702            // Update base for next commit simulation.
703            // Use the synthetic tree from merge-tree output for proper conflict chaining.
704            // This simulates applying each commit on top of the previous result.
705            current_base = result_tree;
706        }
707
708        Ok(predictions)
709    }
710
711    /// Abort an in-progress rebase.
712    ///
713    /// # Errors
714    /// Returns error if abort fails.
715    pub fn rebase_abort(&self) -> Result<()> {
716        let workdir = self.workdir().ok_or(Error::NotARepository)?;
717
718        let output = std::process::Command::new("git")
719            .args(["rebase", "--abort"])
720            .current_dir(workdir)
721            .output()
722            .map_err(|e| Error::RebaseFailed(e.to_string()))?;
723
724        if output.status.success() {
725            Ok(())
726        } else {
727            let stderr = String::from_utf8_lossy(&output.stderr);
728            Err(Error::RebaseFailed(stderr.to_string()))
729        }
730    }
731
732    /// Continue an in-progress rebase.
733    ///
734    /// # Errors
735    /// Returns error if continue fails or new conflicts occur.
736    pub fn rebase_continue(&self) -> Result<()> {
737        let workdir = self.workdir().ok_or(Error::NotARepository)?;
738
739        let output = std::process::Command::new("git")
740            .args(["rebase", "--continue"])
741            .current_dir(workdir)
742            .output()
743            .map_err(|e| Error::RebaseFailed(e.to_string()))?;
744
745        if output.status.success() {
746            return Ok(());
747        }
748
749        // Check if it's a conflict
750        if self.is_rebasing() {
751            let conflicts = self.conflicting_files()?;
752            return Err(Error::RebaseConflict(conflicts));
753        }
754
755        let stderr = String::from_utf8_lossy(&output.stderr);
756        Err(Error::RebaseFailed(stderr.to_string()))
757    }
758
759    // === Remote operations ===
760
761    /// Check how a local branch relates to its remote counterpart.
762    ///
763    /// Uses the configured upstream if set, otherwise falls back to `origin/<branch>`.
764    /// Compares the local branch tip with the remote tracking branch to determine
765    /// if the local branch is ahead, behind, diverged, or in sync with the remote.
766    ///
767    /// Uses `graph_ahead_behind` for efficient single-traversal computation.
768    ///
769    /// # Errors
770    /// Returns error if branch doesn't exist or git operations fail.
771    pub fn remote_divergence(&self, branch: &str) -> Result<RemoteDivergence> {
772        let local = self.branch_commit(branch)?;
773
774        // Try to get remote - NoRemote if doesn't exist
775        let remote = match self.remote_branch_commit(branch) {
776            Ok(oid) => oid,
777            Err(Error::BranchNotFound(_)) => return Ok(RemoteDivergence::NoRemote),
778            Err(e) => return Err(e),
779        };
780
781        if local == remote {
782            return Ok(RemoteDivergence::InSync);
783        }
784
785        // Use graph_ahead_behind for efficient single-traversal computation.
786        // NotFound means no merge base (unrelated histories) - treat as (0, 0).
787        let (ahead, behind) = match self.inner.graph_ahead_behind(local, remote) {
788            Ok(counts) => counts,
789            Err(e) if e.code() == git2::ErrorCode::NotFound => (0, 0),
790            Err(e) => return Err(Error::Git2(e)),
791        };
792
793        // Unrelated histories: (0, 0) but local != remote. Count all commits on each side.
794        if ahead == 0 && behind == 0 {
795            return Ok(RemoteDivergence::Diverged {
796                ahead: self.count_all_commits(local)?,
797                behind: self.count_all_commits(remote)?,
798            });
799        }
800
801        Ok(match (ahead, behind) {
802            (a, 0) => RemoteDivergence::Ahead { commits: a },
803            (0, b) => RemoteDivergence::Behind { commits: b },
804            (a, b) => RemoteDivergence::Diverged {
805                ahead: a,
806                behind: b,
807            },
808        })
809    }
810
811    /// Count all commits reachable from a given commit.
812    ///
813    /// Used for unrelated histories where there's no merge base.
814    fn count_all_commits(&self, from: Oid) -> Result<usize> {
815        let mut revwalk = self.inner.revwalk()?;
816        revwalk.push(from)?;
817        Ok(revwalk.count())
818    }
819
820    /// Get the URL of the origin remote.
821    ///
822    /// # Errors
823    /// Returns error if origin remote is not found.
824    pub fn origin_url(&self) -> Result<String> {
825        let remote = self
826            .inner
827            .find_remote("origin")
828            .map_err(|_| Error::RemoteNotFound("origin".into()))?;
829
830        remote
831            .url()
832            .map(String::from)
833            .ok_or_else(|| Error::RemoteNotFound("origin".into()))
834    }
835
836    /// Detect the default branch from the remote's HEAD.
837    ///
838    /// Checks `refs/remotes/origin/HEAD` to determine the remote's default branch.
839    /// Returns `None` if the remote HEAD is not set (e.g., fresh clone without `--set-upstream`).
840    #[must_use]
841    pub fn detect_default_branch(&self) -> Option<String> {
842        // Try to resolve refs/remotes/origin/HEAD which points to the default branch
843        let reference = self.inner.find_reference("refs/remotes/origin/HEAD").ok()?;
844
845        // Resolve the symbolic reference to get the actual branch
846        let resolved = reference.resolve().ok()?;
847        let name = resolved.name()?;
848
849        // Extract branch name from "refs/remotes/origin/main" -> "main"
850        name.strip_prefix("refs/remotes/origin/").map(String::from)
851    }
852
853    /// Parse owner and repo name from a GitHub URL.
854    ///
855    /// Supports both HTTPS and SSH URLs:
856    /// - `https://github.com/owner/repo.git`
857    /// - `git@github.com:owner/repo.git`
858    ///
859    /// # Errors
860    /// Returns error if URL cannot be parsed.
861    pub fn parse_github_remote(url: &str) -> Result<(String, String)> {
862        // SSH format: git@github.com:owner/repo.git
863        if let Some(rest) = url.strip_prefix("git@github.com:") {
864            let path = rest.strip_suffix(".git").unwrap_or(rest);
865            if let Some((owner, repo)) = path.split_once('/') {
866                return Ok((owner.to_string(), repo.to_string()));
867            }
868        }
869
870        // HTTPS format: https://github.com/owner/repo.git
871        if let Some(rest) = url
872            .strip_prefix("https://github.com/")
873            .or_else(|| url.strip_prefix("http://github.com/"))
874        {
875            let path = rest.strip_suffix(".git").unwrap_or(rest);
876            if let Some((owner, repo)) = path.split_once('/') {
877                return Ok((owner.to_string(), repo.to_string()));
878            }
879        }
880
881        Err(Error::InvalidRemoteUrl(url.to_string()))
882    }
883
884    /// Push a branch to the remote.
885    ///
886    /// # Errors
887    /// Returns error if push fails.
888    pub fn push(&self, branch: &str, force: bool) -> Result<()> {
889        let workdir = self.workdir().ok_or(Error::NotARepository)?;
890
891        let mut args = vec!["push", "-u", "origin", branch];
892        if force {
893            args.insert(1, "--force-with-lease");
894        }
895
896        let output = std::process::Command::new("git")
897            .args(&args)
898            .current_dir(workdir)
899            .output()
900            .map_err(|e| Error::PushFailed(e.to_string()))?;
901
902        if output.status.success() {
903            Ok(())
904        } else {
905            let stderr = String::from_utf8_lossy(&output.stderr);
906            Err(Error::PushFailed(stderr.to_string()))
907        }
908    }
909
910    /// Fetch all remote tracking refs from origin.
911    ///
912    /// # Errors
913    /// Returns error if fetch fails.
914    pub fn fetch_all(&self) -> Result<()> {
915        let workdir = self.workdir().ok_or(Error::NotARepository)?;
916
917        let output = std::process::Command::new("git")
918            .args(["fetch", "origin", "--prune"])
919            .current_dir(workdir)
920            .output()
921            .map_err(|e| Error::FetchFailed(e.to_string()))?;
922
923        if output.status.success() {
924            Ok(())
925        } else {
926            let stderr = String::from_utf8_lossy(&output.stderr);
927            Err(Error::FetchFailed(stderr.to_string()))
928        }
929    }
930
931    /// Fetch a branch from origin.
932    ///
933    /// # Errors
934    /// Returns error if fetch fails.
935    pub fn fetch(&self, branch: &str) -> Result<()> {
936        let workdir = self.workdir().ok_or(Error::NotARepository)?;
937
938        // Use refspec to update both remote tracking branch and local branch
939        // Format: origin/branch:refs/heads/branch
940        let refspec = format!("{branch}:refs/heads/{branch}");
941        let output = std::process::Command::new("git")
942            .args(["fetch", "origin", &refspec])
943            .current_dir(workdir)
944            .output()
945            .map_err(|e| Error::FetchFailed(e.to_string()))?;
946
947        if output.status.success() {
948            Ok(())
949        } else {
950            let stderr = String::from_utf8_lossy(&output.stderr);
951            Err(Error::FetchFailed(stderr.to_string()))
952        }
953    }
954
955    /// Pull (fast-forward only) the current branch from origin.
956    ///
957    /// This fetches and merges `origin/<branch>` into the current branch,
958    /// but only if it can be fast-forwarded.
959    ///
960    /// # Errors
961    /// Returns error if pull fails or fast-forward is not possible.
962    pub fn pull_ff(&self) -> Result<()> {
963        let workdir = self.workdir().ok_or(Error::NotARepository)?;
964
965        let output = std::process::Command::new("git")
966            .args(["pull", "--ff-only"])
967            .current_dir(workdir)
968            .output()
969            .map_err(|e| Error::FetchFailed(e.to_string()))?;
970
971        if output.status.success() {
972            Ok(())
973        } else {
974            let stderr = String::from_utf8_lossy(&output.stderr);
975            Err(Error::FetchFailed(stderr.to_string()))
976        }
977    }
978
979    // === Low-level access ===
980
981    /// Get a reference to the underlying git2 repository.
982    ///
983    /// Use sparingly - prefer high-level methods.
984    #[must_use]
985    pub const fn inner(&self) -> &git2::Repository {
986        &self.inner
987    }
988}
989
990impl std::fmt::Debug for Repository {
991    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
992        f.debug_struct("Repository")
993            .field("path", &self.git_dir())
994            .finish()
995    }
996}
997
998// === Trait Implementation ===
999
1000impl GitOps for Repository {
1001    fn workdir(&self) -> Option<&Path> {
1002        Self::workdir(self)
1003    }
1004
1005    fn current_branch(&self) -> Result<String> {
1006        Self::current_branch(self)
1007    }
1008
1009    fn head_detached(&self) -> Result<bool> {
1010        Self::head_detached(self)
1011    }
1012
1013    fn is_rebasing(&self) -> bool {
1014        Self::is_rebasing(self)
1015    }
1016
1017    fn branch_exists(&self, name: &str) -> bool {
1018        Self::branch_exists(self, name)
1019    }
1020
1021    fn create_branch(&self, name: &str) -> Result<Oid> {
1022        Self::create_branch(self, name)
1023    }
1024
1025    fn checkout(&self, branch: &str) -> Result<()> {
1026        Self::checkout(self, branch)
1027    }
1028
1029    fn delete_branch(&self, name: &str) -> Result<()> {
1030        Self::delete_branch(self, name)
1031    }
1032
1033    fn list_branches(&self) -> Result<Vec<String>> {
1034        Self::list_branches(self)
1035    }
1036
1037    fn branch_commit(&self, branch: &str) -> Result<Oid> {
1038        Self::branch_commit(self, branch)
1039    }
1040
1041    fn remote_branch_commit(&self, branch: &str) -> Result<Oid> {
1042        Self::remote_branch_commit(self, branch)
1043    }
1044
1045    fn branch_commit_message(&self, branch: &str) -> Result<String> {
1046        Self::branch_commit_message(self, branch)
1047    }
1048
1049    fn merge_base(&self, one: Oid, two: Oid) -> Result<Oid> {
1050        Self::merge_base(self, one, two)
1051    }
1052
1053    fn commits_between(&self, from: Oid, to: Oid) -> Result<Vec<Oid>> {
1054        Self::commits_between(self, from, to)
1055    }
1056
1057    fn count_commits_between(&self, from: Oid, to: Oid) -> Result<usize> {
1058        Self::count_commits_between(self, from, to)
1059    }
1060
1061    fn is_clean(&self) -> Result<bool> {
1062        Self::is_clean(self)
1063    }
1064
1065    fn require_clean(&self) -> Result<()> {
1066        Self::require_clean(self)
1067    }
1068
1069    fn stage_all(&self) -> Result<()> {
1070        Self::stage_all(self)
1071    }
1072
1073    fn has_staged_changes(&self) -> Result<bool> {
1074        Self::has_staged_changes(self)
1075    }
1076
1077    fn create_commit(&self, message: &str) -> Result<Oid> {
1078        Self::create_commit(self, message)
1079    }
1080
1081    fn amend_commit(&self, new_message: Option<&str>) -> Result<Oid> {
1082        Self::amend_commit(self, new_message)
1083    }
1084
1085    fn rebase_onto(&self, target: Oid) -> Result<()> {
1086        Self::rebase_onto(self, target)
1087    }
1088
1089    fn rebase_onto_from(&self, onto: Oid, from: Oid) -> Result<()> {
1090        Self::rebase_onto_from(self, onto, from)
1091    }
1092
1093    fn conflicting_files(&self) -> Result<Vec<String>> {
1094        Self::conflicting_files(self)
1095    }
1096
1097    fn predict_rebase_conflicts(&self, branch: &str, onto: Oid) -> Result<Vec<ConflictPrediction>> {
1098        Self::predict_rebase_conflicts(self, branch, onto)
1099    }
1100
1101    fn rebase_abort(&self) -> Result<()> {
1102        Self::rebase_abort(self)
1103    }
1104
1105    fn rebase_continue(&self) -> Result<()> {
1106        Self::rebase_continue(self)
1107    }
1108
1109    fn origin_url(&self) -> Result<String> {
1110        Self::origin_url(self)
1111    }
1112
1113    fn remote_divergence(&self, branch: &str) -> Result<RemoteDivergence> {
1114        Self::remote_divergence(self, branch)
1115    }
1116
1117    fn detect_default_branch(&self) -> Option<String> {
1118        Self::detect_default_branch(self)
1119    }
1120
1121    fn push(&self, branch: &str, force: bool) -> Result<()> {
1122        Self::push(self, branch, force)
1123    }
1124
1125    fn fetch_all(&self) -> Result<()> {
1126        Self::fetch_all(self)
1127    }
1128
1129    fn fetch(&self, branch: &str) -> Result<()> {
1130        Self::fetch(self, branch)
1131    }
1132
1133    fn pull_ff(&self) -> Result<()> {
1134        Self::pull_ff(self)
1135    }
1136
1137    fn reset_branch(&self, branch: &str, commit: Oid) -> Result<()> {
1138        Self::reset_branch(self, branch, commit)
1139    }
1140}
1141
1142#[cfg(test)]
1143#[allow(clippy::unwrap_used)]
1144mod tests {
1145    use super::*;
1146    use std::fs;
1147    use tempfile::TempDir;
1148
1149    fn init_test_repo() -> (TempDir, Repository) {
1150        let temp = TempDir::new().unwrap();
1151        let repo = git2::Repository::init(temp.path()).unwrap();
1152
1153        // Configure git user for CLI operations (required for amend_commit tests in CI)
1154        let mut config = repo.config().unwrap();
1155        config.set_str("user.name", "Test").unwrap();
1156        config.set_str("user.email", "test@example.com").unwrap();
1157
1158        // Create initial commit with owned signature (avoids borrowing repo)
1159        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
1160        let tree_id = repo.index().unwrap().write_tree().unwrap();
1161        let tree = repo.find_tree(tree_id).unwrap();
1162        repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
1163            .unwrap();
1164        drop(tree);
1165
1166        let wrapped = Repository { inner: repo };
1167        (temp, wrapped)
1168    }
1169
1170    #[test]
1171    fn test_current_branch() {
1172        let (_temp, repo) = init_test_repo();
1173        // Default branch after init
1174        let branch = repo.current_branch().unwrap();
1175        assert!(branch == "main" || branch == "master");
1176    }
1177
1178    #[test]
1179    fn test_create_and_checkout_branch() {
1180        let (_temp, repo) = init_test_repo();
1181
1182        repo.create_branch("feature/test").unwrap();
1183        assert!(repo.branch_exists("feature/test"));
1184
1185        repo.checkout("feature/test").unwrap();
1186        assert_eq!(repo.current_branch().unwrap(), "feature/test");
1187    }
1188
1189    #[test]
1190    fn test_is_clean() {
1191        let (temp, repo) = init_test_repo();
1192
1193        assert!(repo.is_clean().unwrap());
1194
1195        // Create and commit a tracked file
1196        fs::write(temp.path().join("test.txt"), "initial").unwrap();
1197        {
1198            let mut index = repo.inner.index().unwrap();
1199            index.add_path(std::path::Path::new("test.txt")).unwrap();
1200            index.write().unwrap();
1201            let tree_id = index.write_tree().unwrap();
1202            let tree = repo.inner.find_tree(tree_id).unwrap();
1203            let parent = repo.inner.head().unwrap().peel_to_commit().unwrap();
1204            let sig = git2::Signature::now("Test", "test@example.com").unwrap();
1205            repo.inner
1206                .commit(Some("HEAD"), &sig, &sig, "Add test file", &tree, &[&parent])
1207                .unwrap();
1208        }
1209
1210        // Should still be clean after commit
1211        assert!(repo.is_clean().unwrap());
1212
1213        // Modify tracked file
1214        fs::write(temp.path().join("test.txt"), "modified").unwrap();
1215        assert!(!repo.is_clean().unwrap());
1216    }
1217
1218    #[test]
1219    fn test_list_branches() {
1220        let (_temp, repo) = init_test_repo();
1221
1222        repo.create_branch("feature/a").unwrap();
1223        repo.create_branch("feature/b").unwrap();
1224
1225        let branches = repo.list_branches().unwrap();
1226        assert!(branches.len() >= 3); // main/master + 2 features
1227        assert!(branches.iter().any(|b| b == "feature/a"));
1228        assert!(branches.iter().any(|b| b == "feature/b"));
1229    }
1230
1231    #[test]
1232    fn test_amend_commit_preserves_message() {
1233        let (temp, repo) = init_test_repo();
1234
1235        // Create and commit a file
1236        fs::write(temp.path().join("test.txt"), "initial").unwrap();
1237        repo.stage_all().unwrap();
1238        repo.create_commit("Original message").unwrap();
1239
1240        let original_msg = repo
1241            .branch_commit_message(&repo.current_branch().unwrap())
1242            .unwrap();
1243        assert!(original_msg.starts_with("Original message"));
1244
1245        // Modify file and amend
1246        fs::write(temp.path().join("test.txt"), "modified").unwrap();
1247        repo.stage_all().unwrap();
1248        repo.amend_commit(None).unwrap();
1249
1250        // Message should be preserved
1251        let amended_msg = repo
1252            .branch_commit_message(&repo.current_branch().unwrap())
1253            .unwrap();
1254        assert!(amended_msg.starts_with("Original message"));
1255    }
1256
1257    #[test]
1258    fn test_amend_commit_with_new_message() {
1259        let (temp, repo) = init_test_repo();
1260
1261        // Create and commit a file
1262        fs::write(temp.path().join("test.txt"), "initial").unwrap();
1263        repo.stage_all().unwrap();
1264        repo.create_commit("Original message").unwrap();
1265
1266        // Modify file and amend with new message
1267        fs::write(temp.path().join("test.txt"), "modified").unwrap();
1268        repo.stage_all().unwrap();
1269        repo.amend_commit(Some("Updated message")).unwrap();
1270
1271        // Message should be updated
1272        let amended_msg = repo
1273            .branch_commit_message(&repo.current_branch().unwrap())
1274            .unwrap();
1275        assert!(amended_msg.starts_with("Updated message"));
1276    }
1277
1278    #[test]
1279    fn test_amend_commit_includes_staged_changes() {
1280        let (temp, repo) = init_test_repo();
1281
1282        // Create and commit a file
1283        fs::write(temp.path().join("test.txt"), "initial").unwrap();
1284        repo.stage_all().unwrap();
1285        let first_commit = repo.create_commit("First commit").unwrap();
1286
1287        // Modify file and amend
1288        fs::write(temp.path().join("test.txt"), "modified").unwrap();
1289        repo.stage_all().unwrap();
1290        let amended_commit = repo.amend_commit(None).unwrap();
1291
1292        // Should create a new commit (different OID)
1293        assert_ne!(first_commit, amended_commit);
1294
1295        // Working directory should be clean
1296        assert!(repo.is_clean().unwrap());
1297    }
1298
1299    // === Conflict Prediction Tests ===
1300
1301    /// Helper to create a commit with a specific file content
1302    fn create_commit_with_file(
1303        temp: &TempDir,
1304        repo: &Repository,
1305        filename: &str,
1306        content: &str,
1307        message: &str,
1308    ) -> Oid {
1309        fs::write(temp.path().join(filename), content).unwrap();
1310        repo.stage_all().unwrap();
1311        repo.create_commit(message).unwrap()
1312    }
1313
1314    /// Helper to force checkout a branch (handles dirty working directory)
1315    fn force_checkout(repo: &Repository, branch_name: &str) {
1316        let branch = repo
1317            .inner
1318            .find_branch(branch_name, BranchType::Local)
1319            .unwrap();
1320        let reference = branch.get();
1321        let object = reference.peel(git2::ObjectType::Commit).unwrap();
1322
1323        let mut checkout_opts = git2::build::CheckoutBuilder::new();
1324        checkout_opts.force();
1325
1326        repo.inner
1327            .checkout_tree(&object, Some(&mut checkout_opts))
1328            .unwrap();
1329        repo.inner
1330            .set_head(&format!("refs/heads/{branch_name}"))
1331            .unwrap();
1332    }
1333
1334    #[test]
1335    fn test_predict_rebase_conflicts_no_conflicts() {
1336        let (temp, repo) = init_test_repo();
1337        let main_branch = repo.current_branch().unwrap();
1338
1339        // Create a file on main
1340        create_commit_with_file(&temp, &repo, "file.txt", "main content", "Main commit");
1341
1342        // Create feature branch
1343        repo.create_branch("feature").unwrap();
1344        repo.checkout("feature").unwrap();
1345
1346        // Add a different file on feature
1347        create_commit_with_file(
1348            &temp,
1349            &repo,
1350            "feature.txt",
1351            "feature content",
1352            "Feature commit",
1353        );
1354
1355        // Get main's tip
1356        repo.checkout(&main_branch).unwrap();
1357        let main_tip = repo.branch_commit(&main_branch).unwrap();
1358
1359        // Predict conflicts - should be empty (different files)
1360        let predictions = repo.predict_rebase_conflicts("feature", main_tip).unwrap();
1361        assert!(predictions.is_empty(), "Expected no conflicts");
1362    }
1363
1364    #[test]
1365    fn test_predict_rebase_conflicts_with_conflict() {
1366        let (temp, repo) = init_test_repo();
1367        let main_branch = repo.current_branch().unwrap();
1368
1369        // Create a file on main
1370        create_commit_with_file(&temp, &repo, "shared.txt", "original\n", "Initial shared");
1371
1372        // Save this commit as the base (where feature will branch from)
1373        let base_commit = repo.branch_commit(&main_branch).unwrap();
1374
1375        // Create feature branch from this point
1376        repo.create_branch("feature").unwrap();
1377        repo.checkout("feature").unwrap();
1378
1379        // Modify the file on feature
1380        let feature_commit = create_commit_with_file(
1381            &temp,
1382            &repo,
1383            "shared.txt",
1384            "feature modification\n",
1385            "Feature changes shared",
1386        );
1387
1388        // Go back to main and make a conflicting change
1389        // Use force checkout to avoid conflict with working directory
1390        force_checkout(&repo, &main_branch);
1391
1392        let main_tip = create_commit_with_file(
1393            &temp,
1394            &repo,
1395            "shared.txt",
1396            "main modification\n",
1397            "Main changes shared",
1398        );
1399
1400        // Verify the setup
1401        let merge_base = repo.merge_base(feature_commit, main_tip).unwrap();
1402        assert_eq!(
1403            merge_base, base_commit,
1404            "Merge base should be the original shared commit"
1405        );
1406
1407        let commits = repo.commits_between(merge_base, feature_commit).unwrap();
1408        assert_eq!(commits.len(), 1, "Should have 1 commit to replay");
1409
1410        // Predict conflicts - should detect conflict in shared.txt
1411        let predictions = repo.predict_rebase_conflicts("feature", main_tip).unwrap();
1412        assert!(
1413            !predictions.is_empty(),
1414            "Expected conflicts, got {predictions:?}"
1415        );
1416        assert!(
1417            predictions[0]
1418                .conflicting_files
1419                .iter()
1420                .any(|f| f == "shared.txt"),
1421            "Expected shared.txt to conflict"
1422        );
1423    }
1424
1425    #[test]
1426    fn test_predict_rebase_conflicts_multiple_commits() {
1427        let (temp, repo) = init_test_repo();
1428        let main_branch = repo.current_branch().unwrap();
1429
1430        // Create initial file
1431        create_commit_with_file(
1432            &temp,
1433            &repo,
1434            "file.txt",
1435            "line 1\nline 2\nline 3\n",
1436            "Initial",
1437        );
1438
1439        let base_commit = repo.branch_commit(&main_branch).unwrap();
1440
1441        // Create feature branch
1442        repo.create_branch("feature").unwrap();
1443        repo.checkout("feature").unwrap();
1444
1445        // Make multiple commits on feature
1446        create_commit_with_file(
1447            &temp,
1448            &repo,
1449            "file.txt",
1450            "line 1 modified\nline 2\nline 3\n",
1451            "Commit 1",
1452        );
1453        create_commit_with_file(
1454            &temp,
1455            &repo,
1456            "other.txt",
1457            "other content\n",
1458            "Commit 2 - different file",
1459        );
1460
1461        // Go back to main and make conflicting changes
1462        // Use force checkout to avoid conflict with working directory
1463        force_checkout(&repo, &main_branch);
1464        repo.reset_branch(&main_branch, base_commit).unwrap();
1465        force_checkout(&repo, &main_branch);
1466
1467        create_commit_with_file(
1468            &temp,
1469            &repo,
1470            "file.txt",
1471            "line 1 from main\nline 2\nline 3\n",
1472            "Main modifies line 1",
1473        );
1474
1475        let main_tip = repo.branch_commit(&main_branch).unwrap();
1476
1477        // Predict conflicts
1478        let predictions = repo.predict_rebase_conflicts("feature", main_tip).unwrap();
1479
1480        // Should have at least one conflict (first commit modifies same line as main)
1481        assert!(
1482            !predictions.is_empty(),
1483            "Expected at least one conflicting commit"
1484        );
1485    }
1486
1487    #[test]
1488    fn test_predict_rebase_conflicts_already_synced() {
1489        let (temp, repo) = init_test_repo();
1490        let main_branch = repo.current_branch().unwrap();
1491
1492        // Create a file on main
1493        create_commit_with_file(&temp, &repo, "file.txt", "content", "Main commit");
1494
1495        // Create feature branch at current HEAD
1496        repo.create_branch("feature").unwrap();
1497
1498        // Feature is already at main's tip, so no commits to rebase
1499        let main_tip = repo.branch_commit(&main_branch).unwrap();
1500        let predictions = repo.predict_rebase_conflicts("feature", main_tip).unwrap();
1501        assert!(
1502            predictions.is_empty(),
1503            "Already synced should have no predictions"
1504        );
1505    }
1506
1507    /// Regression test for tree chaining in conflict prediction.
1508    ///
1509    /// This test verifies that when simulating a rebase with multiple commits,
1510    /// we use the synthetic tree OID from each merge-tree output as the base
1511    /// for the next commit simulation. Without proper tree chaining, the second
1512    /// commit's conflict detection would fail because it wouldn't account for
1513    /// the changes from the first commit.
1514    #[test]
1515    fn test_predict_rebase_conflicts_tree_chaining() {
1516        let (temp, repo) = init_test_repo();
1517        let main_branch = repo.current_branch().unwrap();
1518
1519        // Create initial file on main
1520        create_commit_with_file(
1521            &temp,
1522            &repo,
1523            "shared.txt",
1524            "line 1\nline 2\nline 3\n",
1525            "Initial shared file",
1526        );
1527
1528        let base_commit = repo.branch_commit(&main_branch).unwrap();
1529
1530        // Create feature branch
1531        repo.create_branch("feature").unwrap();
1532        repo.checkout("feature").unwrap();
1533
1534        // Commit 1: Add a NEW file (will rebase cleanly - no conflict)
1535        create_commit_with_file(
1536            &temp,
1537            &repo,
1538            "feature_only.txt",
1539            "feature content\n",
1540            "Add feature-only file",
1541        );
1542
1543        // Commit 2: Modify shared.txt line 2 (will conflict with main's changes)
1544        create_commit_with_file(
1545            &temp,
1546            &repo,
1547            "shared.txt",
1548            "line 1\nline 2 from feature\nline 3\n",
1549            "Modify shared line 2",
1550        );
1551
1552        // Go back to main and make conflicting changes to line 2
1553        force_checkout(&repo, &main_branch);
1554        repo.reset_branch(&main_branch, base_commit).unwrap();
1555        force_checkout(&repo, &main_branch);
1556
1557        create_commit_with_file(
1558            &temp,
1559            &repo,
1560            "shared.txt",
1561            "line 1\nline 2 from main\nline 3\n",
1562            "Main modifies line 2",
1563        );
1564
1565        let main_tip = repo.branch_commit(&main_branch).unwrap();
1566
1567        // Predict conflicts
1568        let predictions = repo.predict_rebase_conflicts("feature", main_tip).unwrap();
1569
1570        // Key assertion: Only ONE commit should conflict (the second one that modifies shared.txt)
1571        // The first commit adding feature_only.txt should rebase cleanly.
1572        // If tree chaining is broken, we might get 0 conflicts (wrong base) or 2 conflicts.
1573        assert_eq!(
1574            predictions.len(),
1575            1,
1576            "Expected exactly one conflicting commit (the second one). \
1577             Got {} conflicts. This indicates tree chaining may be broken.",
1578            predictions.len()
1579        );
1580
1581        // Verify it's the correct commit that conflicts
1582        assert!(
1583            predictions[0]
1584                .commit_summary
1585                .contains("Modify shared line 2"),
1586            "Expected the second commit to conflict, got: {}",
1587            predictions[0].commit_summary
1588        );
1589
1590        // Verify the conflicting file
1591        assert!(
1592            predictions[0]
1593                .conflicting_files
1594                .contains(&"shared.txt".to_string()),
1595            "Expected shared.txt to be the conflicting file"
1596        );
1597    }
1598}