cascade_cli/git/
repository.rs

1use crate::errors::{CascadeError, Result};
2use chrono;
3use dialoguer::{theme::ColorfulTheme, Confirm};
4use git2::{Oid, Repository, Signature};
5use std::path::{Path, PathBuf};
6use tracing::{info, warn};
7
8/// Repository information
9#[derive(Debug, Clone)]
10pub struct RepositoryInfo {
11    pub path: PathBuf,
12    pub head_branch: Option<String>,
13    pub head_commit: Option<String>,
14    pub is_dirty: bool,
15    pub untracked_files: Vec<String>,
16}
17
18/// Backup information for force push operations
19#[derive(Debug, Clone)]
20struct ForceBackupInfo {
21    pub backup_branch_name: String,
22    pub remote_commit_id: String,
23    #[allow(dead_code)] // Used for logging/display purposes
24    pub commits_that_would_be_lost: usize,
25}
26
27/// Safety information for branch deletion operations
28#[derive(Debug, Clone)]
29struct BranchDeletionSafety {
30    pub unpushed_commits: Vec<String>,
31    pub remote_tracking_branch: Option<String>,
32    pub is_merged_to_main: bool,
33    pub main_branch_name: String,
34}
35
36/// Safety information for checkout operations
37#[derive(Debug, Clone)]
38struct CheckoutSafety {
39    #[allow(dead_code)] // Used in confirmation dialogs and future features
40    pub has_uncommitted_changes: bool,
41    pub modified_files: Vec<String>,
42    pub staged_files: Vec<String>,
43    pub untracked_files: Vec<String>,
44    #[allow(dead_code)] // Reserved for future automatic stashing implementation
45    pub stash_created: Option<String>,
46    #[allow(dead_code)] // Used for context in confirmation dialogs
47    pub current_branch: Option<String>,
48}
49
50/// Wrapper around git2::Repository with safe operations
51///
52/// For thread safety, use the async variants (e.g., fetch_async, pull_async)
53/// which automatically handle threading using tokio::spawn_blocking.
54/// The async methods create new repository instances in background threads.
55pub struct GitRepository {
56    repo: Repository,
57    path: PathBuf,
58}
59
60impl GitRepository {
61    /// Open a Git repository at the given path
62    pub fn open(path: &Path) -> Result<Self> {
63        let repo = Repository::discover(path)
64            .map_err(|e| CascadeError::config(format!("Not a git repository: {e}")))?;
65
66        let workdir = repo
67            .workdir()
68            .ok_or_else(|| CascadeError::config("Repository has no working directory"))?
69            .to_path_buf();
70
71        Ok(Self {
72            repo,
73            path: workdir,
74        })
75    }
76
77    /// Get repository information
78    pub fn get_info(&self) -> Result<RepositoryInfo> {
79        let head_branch = self.get_current_branch().ok();
80        let head_commit = self.get_head_commit_hash().ok();
81        let is_dirty = self.is_dirty()?;
82        let untracked_files = self.get_untracked_files()?;
83
84        Ok(RepositoryInfo {
85            path: self.path.clone(),
86            head_branch,
87            head_commit,
88            is_dirty,
89            untracked_files,
90        })
91    }
92
93    /// Get the current branch name
94    pub fn get_current_branch(&self) -> Result<String> {
95        let head = self
96            .repo
97            .head()
98            .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
99
100        if let Some(name) = head.shorthand() {
101            Ok(name.to_string())
102        } else {
103            // Detached HEAD - return commit hash
104            let commit = head
105                .peel_to_commit()
106                .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))?;
107            Ok(format!("HEAD@{}", commit.id()))
108        }
109    }
110
111    /// Get the HEAD commit hash
112    pub fn get_head_commit_hash(&self) -> Result<String> {
113        let head = self
114            .repo
115            .head()
116            .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
117
118        let commit = head
119            .peel_to_commit()
120            .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))?;
121
122        Ok(commit.id().to_string())
123    }
124
125    /// Check if the working directory is dirty (has uncommitted changes)
126    pub fn is_dirty(&self) -> Result<bool> {
127        let statuses = self.repo.statuses(None).map_err(CascadeError::Git)?;
128
129        for status in statuses.iter() {
130            let flags = status.status();
131
132            // Check for any modifications, additions, or deletions
133            if flags.intersects(
134                git2::Status::INDEX_MODIFIED
135                    | git2::Status::INDEX_NEW
136                    | git2::Status::INDEX_DELETED
137                    | git2::Status::WT_MODIFIED
138                    | git2::Status::WT_NEW
139                    | git2::Status::WT_DELETED,
140            ) {
141                return Ok(true);
142            }
143        }
144
145        Ok(false)
146    }
147
148    /// Get list of untracked files
149    pub fn get_untracked_files(&self) -> Result<Vec<String>> {
150        let statuses = self.repo.statuses(None).map_err(CascadeError::Git)?;
151
152        let mut untracked = Vec::new();
153        for status in statuses.iter() {
154            if status.status().contains(git2::Status::WT_NEW) {
155                if let Some(path) = status.path() {
156                    untracked.push(path.to_string());
157                }
158            }
159        }
160
161        Ok(untracked)
162    }
163
164    /// Create a new branch
165    pub fn create_branch(&self, name: &str, target: Option<&str>) -> Result<()> {
166        let target_commit = if let Some(target) = target {
167            // Find the specified target commit/branch
168            let target_obj = self.repo.revparse_single(target).map_err(|e| {
169                CascadeError::branch(format!("Could not find target '{target}': {e}"))
170            })?;
171            target_obj.peel_to_commit().map_err(|e| {
172                CascadeError::branch(format!("Target '{target}' is not a commit: {e}"))
173            })?
174        } else {
175            // Use current HEAD
176            let head = self
177                .repo
178                .head()
179                .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
180            head.peel_to_commit()
181                .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))?
182        };
183
184        self.repo
185            .branch(name, &target_commit, false)
186            .map_err(|e| CascadeError::branch(format!("Could not create branch '{name}': {e}")))?;
187
188        tracing::info!("Created branch '{}'", name);
189        Ok(())
190    }
191
192    /// Switch to a branch with safety checks
193    pub fn checkout_branch(&self, name: &str) -> Result<()> {
194        self.checkout_branch_with_options(name, false)
195    }
196
197    /// Switch to a branch with force option to bypass safety checks
198    pub fn checkout_branch_unsafe(&self, name: &str) -> Result<()> {
199        self.checkout_branch_with_options(name, true)
200    }
201
202    /// Internal branch checkout implementation with safety options
203    fn checkout_branch_with_options(&self, name: &str, force_unsafe: bool) -> Result<()> {
204        info!("Attempting to checkout branch: {}", name);
205
206        // Enhanced safety check: Detect uncommitted work before checkout
207        if !force_unsafe {
208            let safety_result = self.check_checkout_safety(name)?;
209            if let Some(safety_info) = safety_result {
210                // Repository has uncommitted changes, get user confirmation
211                self.handle_checkout_confirmation(name, &safety_info)?;
212            }
213        }
214
215        // Find the branch
216        let branch = self
217            .repo
218            .find_branch(name, git2::BranchType::Local)
219            .map_err(|e| CascadeError::branch(format!("Could not find branch '{name}': {e}")))?;
220
221        let branch_ref = branch.get();
222        let tree = branch_ref.peel_to_tree().map_err(|e| {
223            CascadeError::branch(format!("Could not get tree for branch '{name}': {e}"))
224        })?;
225
226        // Checkout the tree
227        self.repo
228            .checkout_tree(tree.as_object(), None)
229            .map_err(|e| {
230                CascadeError::branch(format!("Could not checkout branch '{name}': {e}"))
231            })?;
232
233        // Update HEAD
234        self.repo
235            .set_head(&format!("refs/heads/{name}"))
236            .map_err(|e| CascadeError::branch(format!("Could not update HEAD to '{name}': {e}")))?;
237
238        tracing::info!("Switched to branch '{}'", name);
239        Ok(())
240    }
241
242    /// Checkout a specific commit (detached HEAD) with safety checks
243    pub fn checkout_commit(&self, commit_hash: &str) -> Result<()> {
244        self.checkout_commit_with_options(commit_hash, false)
245    }
246
247    /// Checkout a specific commit with force option to bypass safety checks
248    pub fn checkout_commit_unsafe(&self, commit_hash: &str) -> Result<()> {
249        self.checkout_commit_with_options(commit_hash, true)
250    }
251
252    /// Internal commit checkout implementation with safety options
253    fn checkout_commit_with_options(&self, commit_hash: &str, force_unsafe: bool) -> Result<()> {
254        info!("Attempting to checkout commit: {}", commit_hash);
255
256        // Enhanced safety check: Detect uncommitted work before checkout
257        if !force_unsafe {
258            let safety_result = self.check_checkout_safety(&format!("commit:{commit_hash}"))?;
259            if let Some(safety_info) = safety_result {
260                // Repository has uncommitted changes, get user confirmation
261                self.handle_checkout_confirmation(&format!("commit {commit_hash}"), &safety_info)?;
262            }
263        }
264
265        let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
266
267        let commit = self.repo.find_commit(oid).map_err(|e| {
268            CascadeError::branch(format!("Could not find commit '{commit_hash}': {e}"))
269        })?;
270
271        let tree = commit.tree().map_err(|e| {
272            CascadeError::branch(format!(
273                "Could not get tree for commit '{commit_hash}': {e}"
274            ))
275        })?;
276
277        // Checkout the tree
278        self.repo
279            .checkout_tree(tree.as_object(), None)
280            .map_err(|e| {
281                CascadeError::branch(format!("Could not checkout commit '{commit_hash}': {e}"))
282            })?;
283
284        // Update HEAD to the commit (detached HEAD)
285        self.repo.set_head_detached(oid).map_err(|e| {
286            CascadeError::branch(format!(
287                "Could not update HEAD to commit '{commit_hash}': {e}"
288            ))
289        })?;
290
291        tracing::info!("Checked out commit '{}' (detached HEAD)", commit_hash);
292        Ok(())
293    }
294
295    /// Check if a branch exists
296    pub fn branch_exists(&self, name: &str) -> bool {
297        self.repo.find_branch(name, git2::BranchType::Local).is_ok()
298    }
299
300    /// Check if a branch exists locally, and if not, attempt to fetch it from remote
301    pub fn branch_exists_or_fetch(&self, name: &str) -> Result<bool> {
302        // 1. Check if branch exists locally first
303        if self.repo.find_branch(name, git2::BranchType::Local).is_ok() {
304            return Ok(true);
305        }
306
307        // 2. Try to fetch it from remote
308        println!("🔍 Branch '{name}' not found locally, trying to fetch from remote...");
309
310        use std::process::Command;
311
312        // Try: git fetch origin release/12.34:release/12.34
313        let fetch_result = Command::new("git")
314            .args(["fetch", "origin", &format!("{name}:{name}")])
315            .current_dir(&self.path)
316            .output();
317
318        match fetch_result {
319            Ok(output) => {
320                if output.status.success() {
321                    println!("✅ Successfully fetched '{name}' from origin");
322                    // 3. Check again locally after fetch
323                    return Ok(self.repo.find_branch(name, git2::BranchType::Local).is_ok());
324                } else {
325                    let stderr = String::from_utf8_lossy(&output.stderr);
326                    tracing::debug!("Failed to fetch branch '{name}': {stderr}");
327                }
328            }
329            Err(e) => {
330                tracing::debug!("Git fetch command failed: {e}");
331            }
332        }
333
334        // 4. Try alternative fetch patterns for common branch naming
335        if name.contains('/') {
336            println!("🔍 Trying alternative fetch patterns...");
337
338            // Try: git fetch origin (to get all refs, then checkout locally)
339            let fetch_all_result = Command::new("git")
340                .args(["fetch", "origin"])
341                .current_dir(&self.path)
342                .output();
343
344            if let Ok(output) = fetch_all_result {
345                if output.status.success() {
346                    // Try to create local branch from remote
347                    let checkout_result = Command::new("git")
348                        .args(["checkout", "-b", name, &format!("origin/{name}")])
349                        .current_dir(&self.path)
350                        .output();
351
352                    if let Ok(checkout_output) = checkout_result {
353                        if checkout_output.status.success() {
354                            println!(
355                                "✅ Successfully created local branch '{name}' from origin/{name}"
356                            );
357                            return Ok(true);
358                        }
359                    }
360                }
361            }
362        }
363
364        // 5. Only fail if it doesn't exist anywhere
365        Ok(false)
366    }
367
368    /// Get the commit hash for a specific branch without switching branches
369    pub fn get_branch_commit_hash(&self, branch_name: &str) -> Result<String> {
370        let branch = self
371            .repo
372            .find_branch(branch_name, git2::BranchType::Local)
373            .map_err(|e| {
374                CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
375            })?;
376
377        let commit = branch.get().peel_to_commit().map_err(|e| {
378            CascadeError::branch(format!(
379                "Could not get commit for branch '{branch_name}': {e}"
380            ))
381        })?;
382
383        Ok(commit.id().to_string())
384    }
385
386    /// List all local branches
387    pub fn list_branches(&self) -> Result<Vec<String>> {
388        let branches = self
389            .repo
390            .branches(Some(git2::BranchType::Local))
391            .map_err(CascadeError::Git)?;
392
393        let mut branch_names = Vec::new();
394        for branch in branches {
395            let (branch, _) = branch.map_err(CascadeError::Git)?;
396            if let Some(name) = branch.name().map_err(CascadeError::Git)? {
397                branch_names.push(name.to_string());
398            }
399        }
400
401        Ok(branch_names)
402    }
403
404    /// Create a commit with all staged changes
405    pub fn commit(&self, message: &str) -> Result<String> {
406        let signature = self.get_signature()?;
407        let tree_id = self.get_index_tree()?;
408        let tree = self.repo.find_tree(tree_id).map_err(CascadeError::Git)?;
409
410        // Get parent commits
411        let head = self.repo.head().map_err(CascadeError::Git)?;
412        let parent_commit = head.peel_to_commit().map_err(CascadeError::Git)?;
413
414        let commit_id = self
415            .repo
416            .commit(
417                Some("HEAD"),
418                &signature,
419                &signature,
420                message,
421                &tree,
422                &[&parent_commit],
423            )
424            .map_err(CascadeError::Git)?;
425
426        tracing::info!("Created commit: {} - {}", commit_id, message);
427        Ok(commit_id.to_string())
428    }
429
430    /// Stage all changes
431    pub fn stage_all(&self) -> Result<()> {
432        let mut index = self.repo.index().map_err(CascadeError::Git)?;
433
434        index
435            .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
436            .map_err(CascadeError::Git)?;
437
438        index.write().map_err(CascadeError::Git)?;
439
440        tracing::debug!("Staged all changes");
441        Ok(())
442    }
443
444    /// Get repository path
445    pub fn path(&self) -> &Path {
446        &self.path
447    }
448
449    /// Check if a commit exists
450    pub fn commit_exists(&self, commit_hash: &str) -> Result<bool> {
451        match Oid::from_str(commit_hash) {
452            Ok(oid) => match self.repo.find_commit(oid) {
453                Ok(_) => Ok(true),
454                Err(_) => Ok(false),
455            },
456            Err(_) => Ok(false),
457        }
458    }
459
460    /// Get the HEAD commit object
461    pub fn get_head_commit(&self) -> Result<git2::Commit<'_>> {
462        let head = self
463            .repo
464            .head()
465            .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
466        head.peel_to_commit()
467            .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))
468    }
469
470    /// Get a commit object by hash
471    pub fn get_commit(&self, commit_hash: &str) -> Result<git2::Commit<'_>> {
472        let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
473
474        self.repo.find_commit(oid).map_err(CascadeError::Git)
475    }
476
477    /// Get the commit hash at the head of a branch
478    pub fn get_branch_head(&self, branch_name: &str) -> Result<String> {
479        let branch = self
480            .repo
481            .find_branch(branch_name, git2::BranchType::Local)
482            .map_err(|e| {
483                CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
484            })?;
485
486        let commit = branch.get().peel_to_commit().map_err(|e| {
487            CascadeError::branch(format!(
488                "Could not get commit for branch '{branch_name}': {e}"
489            ))
490        })?;
491
492        Ok(commit.id().to_string())
493    }
494
495    /// Get a signature for commits
496    fn get_signature(&self) -> Result<Signature<'_>> {
497        // Try to get signature from Git config
498        if let Ok(config) = self.repo.config() {
499            if let (Ok(name), Ok(email)) = (
500                config.get_string("user.name"),
501                config.get_string("user.email"),
502            ) {
503                return Signature::now(&name, &email).map_err(CascadeError::Git);
504            }
505        }
506
507        // Fallback to default signature
508        Signature::now("Cascade CLI", "cascade@example.com").map_err(CascadeError::Git)
509    }
510
511    /// Get the tree ID from the current index
512    fn get_index_tree(&self) -> Result<Oid> {
513        let mut index = self.repo.index().map_err(CascadeError::Git)?;
514
515        index.write_tree().map_err(CascadeError::Git)
516    }
517
518    /// Get repository status
519    pub fn get_status(&self) -> Result<git2::Statuses<'_>> {
520        self.repo.statuses(None).map_err(CascadeError::Git)
521    }
522
523    /// Get remote URL for a given remote name
524    pub fn get_remote_url(&self, name: &str) -> Result<String> {
525        let remote = self.repo.find_remote(name).map_err(CascadeError::Git)?;
526
527        let url = remote.url().ok_or_else(|| {
528            CascadeError::Git(git2::Error::from_str("Remote URL is not valid UTF-8"))
529        })?;
530
531        Ok(url.to_string())
532    }
533
534    /// Cherry-pick a commit onto the current branch
535    pub fn cherry_pick(&self, commit_hash: &str) -> Result<String> {
536        tracing::debug!("Cherry-picking commit {}", commit_hash);
537
538        let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
539        let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
540
541        // Get the commit's tree
542        let commit_tree = commit.tree().map_err(CascadeError::Git)?;
543
544        // Get parent tree for merge base
545        let parent_commit = if commit.parent_count() > 0 {
546            commit.parent(0).map_err(CascadeError::Git)?
547        } else {
548            // Root commit - use empty tree
549            let empty_tree_oid = self.repo.treebuilder(None)?.write()?;
550            let empty_tree = self.repo.find_tree(empty_tree_oid)?;
551            let sig = self.get_signature()?;
552            return self
553                .repo
554                .commit(
555                    Some("HEAD"),
556                    &sig,
557                    &sig,
558                    commit.message().unwrap_or("Cherry-picked commit"),
559                    &empty_tree,
560                    &[],
561                )
562                .map(|oid| oid.to_string())
563                .map_err(CascadeError::Git);
564        };
565
566        let parent_tree = parent_commit.tree().map_err(CascadeError::Git)?;
567
568        // Get current HEAD tree for 3-way merge
569        let head_commit = self.get_head_commit()?;
570        let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
571
572        // Perform 3-way merge
573        let mut index = self
574            .repo
575            .merge_trees(&parent_tree, &head_tree, &commit_tree, None)
576            .map_err(CascadeError::Git)?;
577
578        // Check for conflicts
579        if index.has_conflicts() {
580            return Err(CascadeError::branch(format!(
581                "Cherry-pick of {commit_hash} has conflicts that need manual resolution"
582            )));
583        }
584
585        // Write merged tree
586        let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
587        let merged_tree = self
588            .repo
589            .find_tree(merged_tree_oid)
590            .map_err(CascadeError::Git)?;
591
592        // Create new commit
593        let signature = self.get_signature()?;
594        let message = format!("Cherry-pick: {}", commit.message().unwrap_or(""));
595
596        let new_commit_oid = self
597            .repo
598            .commit(
599                Some("HEAD"),
600                &signature,
601                &signature,
602                &message,
603                &merged_tree,
604                &[&head_commit],
605            )
606            .map_err(CascadeError::Git)?;
607
608        tracing::info!("Cherry-picked {} -> {}", commit_hash, new_commit_oid);
609        Ok(new_commit_oid.to_string())
610    }
611
612    /// Check for merge conflicts in the index
613    pub fn has_conflicts(&self) -> Result<bool> {
614        let index = self.repo.index().map_err(CascadeError::Git)?;
615        Ok(index.has_conflicts())
616    }
617
618    /// Get list of conflicted files
619    pub fn get_conflicted_files(&self) -> Result<Vec<String>> {
620        let index = self.repo.index().map_err(CascadeError::Git)?;
621
622        let mut conflicts = Vec::new();
623
624        // Iterate through index conflicts
625        let conflict_iter = index.conflicts().map_err(CascadeError::Git)?;
626
627        for conflict in conflict_iter {
628            let conflict = conflict.map_err(CascadeError::Git)?;
629            if let Some(our) = conflict.our {
630                if let Ok(path) = std::str::from_utf8(&our.path) {
631                    conflicts.push(path.to_string());
632                }
633            } else if let Some(their) = conflict.their {
634                if let Ok(path) = std::str::from_utf8(&their.path) {
635                    conflicts.push(path.to_string());
636                }
637            }
638        }
639
640        Ok(conflicts)
641    }
642
643    /// Fetch from remote origin
644    pub fn fetch(&self) -> Result<()> {
645        tracing::info!("Fetching from origin");
646
647        let mut remote = self
648            .repo
649            .find_remote("origin")
650            .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
651
652        // Fetch with default refspec
653        remote
654            .fetch::<&str>(&[], None, None)
655            .map_err(CascadeError::Git)?;
656
657        tracing::debug!("Fetch completed successfully");
658        Ok(())
659    }
660
661    /// Pull changes from remote (fetch + merge)
662    pub fn pull(&self, branch: &str) -> Result<()> {
663        tracing::info!("Pulling branch: {}", branch);
664
665        // First fetch
666        self.fetch()?;
667
668        // Get remote tracking branch
669        let remote_branch_name = format!("origin/{branch}");
670        let remote_oid = self
671            .repo
672            .refname_to_id(&format!("refs/remotes/{remote_branch_name}"))
673            .map_err(|e| {
674                CascadeError::branch(format!("Remote branch {remote_branch_name} not found: {e}"))
675            })?;
676
677        let remote_commit = self
678            .repo
679            .find_commit(remote_oid)
680            .map_err(CascadeError::Git)?;
681
682        // Get current HEAD
683        let head_commit = self.get_head_commit()?;
684
685        // Check if we need to merge
686        if head_commit.id() == remote_commit.id() {
687            tracing::debug!("Already up to date");
688            return Ok(());
689        }
690
691        // Perform merge
692        let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
693        let remote_tree = remote_commit.tree().map_err(CascadeError::Git)?;
694
695        // Find merge base
696        let merge_base_oid = self
697            .repo
698            .merge_base(head_commit.id(), remote_commit.id())
699            .map_err(CascadeError::Git)?;
700        let merge_base_commit = self
701            .repo
702            .find_commit(merge_base_oid)
703            .map_err(CascadeError::Git)?;
704        let merge_base_tree = merge_base_commit.tree().map_err(CascadeError::Git)?;
705
706        // 3-way merge
707        let mut index = self
708            .repo
709            .merge_trees(&merge_base_tree, &head_tree, &remote_tree, None)
710            .map_err(CascadeError::Git)?;
711
712        if index.has_conflicts() {
713            return Err(CascadeError::branch(
714                "Pull has conflicts that need manual resolution".to_string(),
715            ));
716        }
717
718        // Write merged tree and create merge commit
719        let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
720        let merged_tree = self
721            .repo
722            .find_tree(merged_tree_oid)
723            .map_err(CascadeError::Git)?;
724
725        let signature = self.get_signature()?;
726        let message = format!("Merge branch '{branch}' from origin");
727
728        self.repo
729            .commit(
730                Some("HEAD"),
731                &signature,
732                &signature,
733                &message,
734                &merged_tree,
735                &[&head_commit, &remote_commit],
736            )
737            .map_err(CascadeError::Git)?;
738
739        tracing::info!("Pull completed successfully");
740        Ok(())
741    }
742
743    /// Push current branch to remote
744    pub fn push(&self, branch: &str) -> Result<()> {
745        tracing::info!("Pushing branch: {}", branch);
746
747        let mut remote = self
748            .repo
749            .find_remote("origin")
750            .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
751
752        let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
753
754        remote.push(&[&refspec], None).map_err(CascadeError::Git)?;
755
756        tracing::info!("Push completed successfully");
757        Ok(())
758    }
759
760    /// Delete a local branch
761    pub fn delete_branch(&self, name: &str) -> Result<()> {
762        self.delete_branch_with_options(name, false)
763    }
764
765    /// Delete a local branch with force option to bypass safety checks
766    pub fn delete_branch_unsafe(&self, name: &str) -> Result<()> {
767        self.delete_branch_with_options(name, true)
768    }
769
770    /// Internal branch deletion implementation with safety options
771    fn delete_branch_with_options(&self, name: &str, force_unsafe: bool) -> Result<()> {
772        info!("Attempting to delete branch: {}", name);
773
774        // Enhanced safety check: Detect unpushed commits before deletion
775        if !force_unsafe {
776            let safety_result = self.check_branch_deletion_safety(name)?;
777            if let Some(safety_info) = safety_result {
778                // Branch has unpushed commits, get user confirmation
779                self.handle_branch_deletion_confirmation(name, &safety_info)?;
780            }
781        }
782
783        let mut branch = self
784            .repo
785            .find_branch(name, git2::BranchType::Local)
786            .map_err(|e| CascadeError::branch(format!("Could not find branch '{name}': {e}")))?;
787
788        branch
789            .delete()
790            .map_err(|e| CascadeError::branch(format!("Could not delete branch '{name}': {e}")))?;
791
792        info!("Successfully deleted branch '{}'", name);
793        Ok(())
794    }
795
796    /// Get commits between two references
797    pub fn get_commits_between(&self, from: &str, to: &str) -> Result<Vec<git2::Commit<'_>>> {
798        let from_oid = self
799            .repo
800            .refname_to_id(&format!("refs/heads/{from}"))
801            .or_else(|_| Oid::from_str(from))
802            .map_err(|e| CascadeError::branch(format!("Invalid from reference '{from}': {e}")))?;
803
804        let to_oid = self
805            .repo
806            .refname_to_id(&format!("refs/heads/{to}"))
807            .or_else(|_| Oid::from_str(to))
808            .map_err(|e| CascadeError::branch(format!("Invalid to reference '{to}': {e}")))?;
809
810        let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
811
812        revwalk.push(to_oid).map_err(CascadeError::Git)?;
813        revwalk.hide(from_oid).map_err(CascadeError::Git)?;
814
815        let mut commits = Vec::new();
816        for oid in revwalk {
817            let oid = oid.map_err(CascadeError::Git)?;
818            let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
819            commits.push(commit);
820        }
821
822        Ok(commits)
823    }
824
825    /// Force push one branch's content to another branch name
826    /// This is used to preserve PR history while updating branch contents after rebase
827    pub fn force_push_branch(&self, target_branch: &str, source_branch: &str) -> Result<()> {
828        self.force_push_branch_with_options(target_branch, source_branch, false)
829    }
830
831    /// Force push with explicit force flag to bypass safety checks
832    pub fn force_push_branch_unsafe(&self, target_branch: &str, source_branch: &str) -> Result<()> {
833        self.force_push_branch_with_options(target_branch, source_branch, true)
834    }
835
836    /// Internal force push implementation with safety options
837    fn force_push_branch_with_options(
838        &self,
839        target_branch: &str,
840        source_branch: &str,
841        force_unsafe: bool,
842    ) -> Result<()> {
843        info!(
844            "Force pushing {} content to {} to preserve PR history",
845            source_branch, target_branch
846        );
847
848        // Enhanced safety check: Detect potential data loss and get user confirmation
849        if !force_unsafe {
850            let safety_result = self.check_force_push_safety_enhanced(target_branch)?;
851            if let Some(backup_info) = safety_result {
852                // Create backup branch before force push
853                self.create_backup_branch(target_branch, &backup_info.remote_commit_id)?;
854                info!(
855                    "✅ Created backup branch: {}",
856                    backup_info.backup_branch_name
857                );
858            }
859        }
860
861        // First, ensure we have the latest changes for the source branch
862        let source_ref = self
863            .repo
864            .find_reference(&format!("refs/heads/{source_branch}"))
865            .map_err(|e| {
866                CascadeError::config(format!("Failed to find source branch {source_branch}: {e}"))
867            })?;
868        let source_commit = source_ref.peel_to_commit().map_err(|e| {
869            CascadeError::config(format!(
870                "Failed to get commit for source branch {source_branch}: {e}"
871            ))
872        })?;
873
874        // Update the target branch to point to the source commit
875        let mut target_ref = self
876            .repo
877            .find_reference(&format!("refs/heads/{target_branch}"))
878            .map_err(|e| {
879                CascadeError::config(format!("Failed to find target branch {target_branch}: {e}"))
880            })?;
881
882        target_ref
883            .set_target(source_commit.id(), "Force push from rebase")
884            .map_err(|e| {
885                CascadeError::config(format!(
886                    "Failed to update target branch {target_branch}: {e}"
887                ))
888            })?;
889
890        // Force push to remote
891        let mut remote = self
892            .repo
893            .find_remote("origin")
894            .map_err(|e| CascadeError::config(format!("Failed to find origin remote: {e}")))?;
895
896        let refspec = format!("+refs/heads/{target_branch}:refs/heads/{target_branch}");
897
898        // Create callbacks for authentication
899        let mut callbacks = git2::RemoteCallbacks::new();
900
901        // Try to use existing authentication from git config/credential manager
902        callbacks.credentials(|_url, username_from_url, _allowed_types| {
903            if let Some(username) = username_from_url {
904                // Try SSH key first
905                git2::Cred::ssh_key_from_agent(username)
906            } else {
907                // Try default credential helper
908                git2::Cred::default()
909            }
910        });
911
912        // Push options for force push
913        let mut push_options = git2::PushOptions::new();
914        push_options.remote_callbacks(callbacks);
915
916        remote
917            .push(&[&refspec], Some(&mut push_options))
918            .map_err(|e| {
919                CascadeError::config(format!("Failed to force push {target_branch}: {e}"))
920            })?;
921
922        info!(
923            "✅ Successfully force pushed {} to preserve PR history",
924            target_branch
925        );
926        Ok(())
927    }
928
929    /// Enhanced safety check for force push operations with user confirmation
930    /// Returns backup info if data would be lost and user confirms
931    fn check_force_push_safety_enhanced(
932        &self,
933        target_branch: &str,
934    ) -> Result<Option<ForceBackupInfo>> {
935        // First fetch latest remote changes to ensure we have up-to-date information
936        match self.fetch() {
937            Ok(_) => {}
938            Err(e) => {
939                // If fetch fails, warn but don't block the operation
940                warn!("Could not fetch latest changes for safety check: {}", e);
941            }
942        }
943
944        // Check if there are commits on the remote that would be lost
945        let remote_ref = format!("refs/remotes/origin/{target_branch}");
946        let local_ref = format!("refs/heads/{target_branch}");
947
948        // Try to find both local and remote references
949        let local_commit = match self.repo.find_reference(&local_ref) {
950            Ok(reference) => reference.peel_to_commit().ok(),
951            Err(_) => None,
952        };
953
954        let remote_commit = match self.repo.find_reference(&remote_ref) {
955            Ok(reference) => reference.peel_to_commit().ok(),
956            Err(_) => None,
957        };
958
959        // If we have both commits, check for divergence
960        if let (Some(local), Some(remote)) = (local_commit, remote_commit) {
961            if local.id() != remote.id() {
962                // Check if the remote has commits that the local doesn't have
963                let merge_base_oid = self
964                    .repo
965                    .merge_base(local.id(), remote.id())
966                    .map_err(|e| CascadeError::config(format!("Failed to find merge base: {e}")))?;
967
968                // If merge base != remote commit, remote has commits that would be lost
969                if merge_base_oid != remote.id() {
970                    let commits_to_lose = self.count_commits_between(
971                        &merge_base_oid.to_string(),
972                        &remote.id().to_string(),
973                    )?;
974
975                    // Create backup branch name with timestamp
976                    let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
977                    let backup_branch_name = format!("{target_branch}_backup_{timestamp}");
978
979                    warn!(
980                        "⚠️  Force push to '{}' would overwrite {} commits on remote",
981                        target_branch, commits_to_lose
982                    );
983
984                    // Check if we're in a non-interactive environment (CI/testing)
985                    if std::env::var("CI").is_ok() || std::env::var("FORCE_PUSH_NO_CONFIRM").is_ok()
986                    {
987                        info!(
988                            "Non-interactive environment detected, proceeding with backup creation"
989                        );
990                        return Ok(Some(ForceBackupInfo {
991                            backup_branch_name,
992                            remote_commit_id: remote.id().to_string(),
993                            commits_that_would_be_lost: commits_to_lose,
994                        }));
995                    }
996
997                    // Interactive confirmation
998                    println!("\n⚠️  FORCE PUSH WARNING ⚠️");
999                    println!("Force push to '{target_branch}' would overwrite {commits_to_lose} commits on remote:");
1000
1001                    // Show the commits that would be lost
1002                    match self
1003                        .get_commits_between(&merge_base_oid.to_string(), &remote.id().to_string())
1004                    {
1005                        Ok(commits) => {
1006                            println!("\nCommits that would be lost:");
1007                            for (i, commit) in commits.iter().take(5).enumerate() {
1008                                let short_hash = &commit.id().to_string()[..8];
1009                                let summary = commit.summary().unwrap_or("<no message>");
1010                                println!("  {}. {} - {}", i + 1, short_hash, summary);
1011                            }
1012                            if commits.len() > 5 {
1013                                println!("  ... and {} more commits", commits.len() - 5);
1014                            }
1015                        }
1016                        Err(_) => {
1017                            println!("  (Unable to retrieve commit details)");
1018                        }
1019                    }
1020
1021                    println!("\nA backup branch '{backup_branch_name}' will be created before proceeding.");
1022
1023                    let confirmed = Confirm::with_theme(&ColorfulTheme::default())
1024                        .with_prompt("Do you want to proceed with the force push?")
1025                        .default(false)
1026                        .interact()
1027                        .map_err(|e| {
1028                            CascadeError::config(format!("Failed to get user confirmation: {e}"))
1029                        })?;
1030
1031                    if !confirmed {
1032                        return Err(CascadeError::config(
1033                            "Force push cancelled by user. Use --force to bypass this check."
1034                                .to_string(),
1035                        ));
1036                    }
1037
1038                    return Ok(Some(ForceBackupInfo {
1039                        backup_branch_name,
1040                        remote_commit_id: remote.id().to_string(),
1041                        commits_that_would_be_lost: commits_to_lose,
1042                    }));
1043                }
1044            }
1045        }
1046
1047        Ok(None)
1048    }
1049
1050    /// Create a backup branch pointing to the remote commit that would be lost
1051    fn create_backup_branch(&self, original_branch: &str, remote_commit_id: &str) -> Result<()> {
1052        let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1053        let backup_branch_name = format!("{original_branch}_backup_{timestamp}");
1054
1055        // Parse the commit ID
1056        let commit_oid = Oid::from_str(remote_commit_id).map_err(|e| {
1057            CascadeError::config(format!("Invalid commit ID {remote_commit_id}: {e}"))
1058        })?;
1059
1060        // Find the commit
1061        let commit = self.repo.find_commit(commit_oid).map_err(|e| {
1062            CascadeError::config(format!("Failed to find commit {remote_commit_id}: {e}"))
1063        })?;
1064
1065        // Create the backup branch
1066        self.repo
1067            .branch(&backup_branch_name, &commit, false)
1068            .map_err(|e| {
1069                CascadeError::config(format!(
1070                    "Failed to create backup branch {backup_branch_name}: {e}"
1071                ))
1072            })?;
1073
1074        info!(
1075            "✅ Created backup branch '{}' pointing to {}",
1076            backup_branch_name,
1077            &remote_commit_id[..8]
1078        );
1079        Ok(())
1080    }
1081
1082    /// Check if branch deletion is safe by detecting unpushed commits
1083    /// Returns safety info if there are concerns that need user attention
1084    fn check_branch_deletion_safety(
1085        &self,
1086        branch_name: &str,
1087    ) -> Result<Option<BranchDeletionSafety>> {
1088        // First, try to fetch latest remote changes
1089        match self.fetch() {
1090            Ok(_) => {}
1091            Err(e) => {
1092                warn!(
1093                    "Could not fetch latest changes for branch deletion safety check: {}",
1094                    e
1095                );
1096            }
1097        }
1098
1099        // Find the branch
1100        let branch = self
1101            .repo
1102            .find_branch(branch_name, git2::BranchType::Local)
1103            .map_err(|e| {
1104                CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
1105            })?;
1106
1107        let _branch_commit = branch.get().peel_to_commit().map_err(|e| {
1108            CascadeError::branch(format!(
1109                "Could not get commit for branch '{branch_name}': {e}"
1110            ))
1111        })?;
1112
1113        // Determine the main branch (try common names)
1114        let main_branch_name = self.detect_main_branch()?;
1115
1116        // Check if branch is merged to main
1117        let is_merged_to_main = self.is_branch_merged_to_main(branch_name, &main_branch_name)?;
1118
1119        // Find the upstream/remote tracking branch
1120        let remote_tracking_branch = self.get_remote_tracking_branch(branch_name);
1121
1122        let mut unpushed_commits = Vec::new();
1123
1124        // Check for unpushed commits compared to remote tracking branch
1125        if let Some(ref remote_branch) = remote_tracking_branch {
1126            match self.get_commits_between(remote_branch, branch_name) {
1127                Ok(commits) => {
1128                    unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1129                }
1130                Err(_) => {
1131                    // If we can't compare with remote, check against main branch
1132                    if !is_merged_to_main {
1133                        if let Ok(commits) =
1134                            self.get_commits_between(&main_branch_name, branch_name)
1135                        {
1136                            unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1137                        }
1138                    }
1139                }
1140            }
1141        } else if !is_merged_to_main {
1142            // No remote tracking branch, check against main
1143            if let Ok(commits) = self.get_commits_between(&main_branch_name, branch_name) {
1144                unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1145            }
1146        }
1147
1148        // If there are concerns, return safety info
1149        if !unpushed_commits.is_empty() || (!is_merged_to_main && remote_tracking_branch.is_none())
1150        {
1151            Ok(Some(BranchDeletionSafety {
1152                unpushed_commits,
1153                remote_tracking_branch,
1154                is_merged_to_main,
1155                main_branch_name,
1156            }))
1157        } else {
1158            Ok(None)
1159        }
1160    }
1161
1162    /// Handle user confirmation for branch deletion with safety concerns
1163    fn handle_branch_deletion_confirmation(
1164        &self,
1165        branch_name: &str,
1166        safety_info: &BranchDeletionSafety,
1167    ) -> Result<()> {
1168        // Check if we're in a non-interactive environment
1169        if std::env::var("CI").is_ok() || std::env::var("BRANCH_DELETE_NO_CONFIRM").is_ok() {
1170            return Err(CascadeError::branch(
1171                format!(
1172                    "Branch '{branch_name}' has {} unpushed commits and cannot be deleted in non-interactive mode. Use --force to override.",
1173                    safety_info.unpushed_commits.len()
1174                )
1175            ));
1176        }
1177
1178        // Interactive warning and confirmation
1179        println!("\n⚠️  BRANCH DELETION WARNING ⚠️");
1180        println!("Branch '{branch_name}' has potential issues:");
1181
1182        if !safety_info.unpushed_commits.is_empty() {
1183            println!(
1184                "\n🔍 Unpushed commits ({} total):",
1185                safety_info.unpushed_commits.len()
1186            );
1187
1188            // Show details of unpushed commits
1189            for (i, commit_id) in safety_info.unpushed_commits.iter().take(5).enumerate() {
1190                if let Ok(commit) = self.repo.find_commit(Oid::from_str(commit_id).unwrap()) {
1191                    let short_hash = &commit_id[..8];
1192                    let summary = commit.summary().unwrap_or("<no message>");
1193                    println!("  {}. {} - {}", i + 1, short_hash, summary);
1194                }
1195            }
1196
1197            if safety_info.unpushed_commits.len() > 5 {
1198                println!(
1199                    "  ... and {} more commits",
1200                    safety_info.unpushed_commits.len() - 5
1201                );
1202            }
1203        }
1204
1205        if !safety_info.is_merged_to_main {
1206            println!("\n📋 Branch status:");
1207            println!("  • Not merged to '{}'", safety_info.main_branch_name);
1208            if let Some(ref remote) = safety_info.remote_tracking_branch {
1209                println!("  • Remote tracking branch: {remote}");
1210            } else {
1211                println!("  • No remote tracking branch");
1212            }
1213        }
1214
1215        println!("\n💡 Safer alternatives:");
1216        if !safety_info.unpushed_commits.is_empty() {
1217            if let Some(ref _remote) = safety_info.remote_tracking_branch {
1218                println!("  • Push commits first: git push origin {branch_name}");
1219            } else {
1220                println!("  • Create and push to remote: git push -u origin {branch_name}");
1221            }
1222        }
1223        if !safety_info.is_merged_to_main {
1224            println!(
1225                "  • Merge to {} first: git checkout {} && git merge {branch_name}",
1226                safety_info.main_branch_name, safety_info.main_branch_name
1227            );
1228        }
1229
1230        let confirmed = Confirm::with_theme(&ColorfulTheme::default())
1231            .with_prompt("Do you want to proceed with deleting this branch?")
1232            .default(false)
1233            .interact()
1234            .map_err(|e| CascadeError::branch(format!("Failed to get user confirmation: {e}")))?;
1235
1236        if !confirmed {
1237            return Err(CascadeError::branch(
1238                "Branch deletion cancelled by user. Use --force to bypass this check.".to_string(),
1239            ));
1240        }
1241
1242        Ok(())
1243    }
1244
1245    /// Detect the main branch name (main, master, develop)
1246    fn detect_main_branch(&self) -> Result<String> {
1247        let main_candidates = ["main", "master", "develop", "trunk"];
1248
1249        for candidate in &main_candidates {
1250            if self
1251                .repo
1252                .find_branch(candidate, git2::BranchType::Local)
1253                .is_ok()
1254            {
1255                return Ok(candidate.to_string());
1256            }
1257        }
1258
1259        // Fallback to HEAD's target if it's a symbolic reference
1260        if let Ok(head) = self.repo.head() {
1261            if let Some(name) = head.shorthand() {
1262                return Ok(name.to_string());
1263            }
1264        }
1265
1266        // Final fallback
1267        Ok("main".to_string())
1268    }
1269
1270    /// Check if a branch is merged to the main branch
1271    fn is_branch_merged_to_main(&self, branch_name: &str, main_branch: &str) -> Result<bool> {
1272        // Get the commits between main and the branch
1273        match self.get_commits_between(main_branch, branch_name) {
1274            Ok(commits) => Ok(commits.is_empty()),
1275            Err(_) => {
1276                // If we can't determine, assume not merged for safety
1277                Ok(false)
1278            }
1279        }
1280    }
1281
1282    /// Get the remote tracking branch for a local branch
1283    fn get_remote_tracking_branch(&self, branch_name: &str) -> Option<String> {
1284        // Try common remote tracking branch patterns
1285        let remote_candidates = [
1286            format!("origin/{branch_name}"),
1287            format!("remotes/origin/{branch_name}"),
1288        ];
1289
1290        for candidate in &remote_candidates {
1291            if self
1292                .repo
1293                .find_reference(&format!(
1294                    "refs/remotes/{}",
1295                    candidate.replace("remotes/", "")
1296                ))
1297                .is_ok()
1298            {
1299                return Some(candidate.clone());
1300            }
1301        }
1302
1303        None
1304    }
1305
1306    /// Check if checkout operation is safe
1307    fn check_checkout_safety(&self, _target: &str) -> Result<Option<CheckoutSafety>> {
1308        // Check if there are uncommitted changes
1309        let is_dirty = self.is_dirty()?;
1310        if !is_dirty {
1311            // No uncommitted changes, checkout is safe
1312            return Ok(None);
1313        }
1314
1315        // Get current branch for context
1316        let current_branch = self.get_current_branch().ok();
1317
1318        // Get detailed information about uncommitted changes
1319        let modified_files = self.get_modified_files()?;
1320        let staged_files = self.get_staged_files()?;
1321        let untracked_files = self.get_untracked_files()?;
1322
1323        let has_uncommitted_changes = !modified_files.is_empty() || !staged_files.is_empty();
1324
1325        if has_uncommitted_changes || !untracked_files.is_empty() {
1326            return Ok(Some(CheckoutSafety {
1327                has_uncommitted_changes,
1328                modified_files,
1329                staged_files,
1330                untracked_files,
1331                stash_created: None,
1332                current_branch,
1333            }));
1334        }
1335
1336        Ok(None)
1337    }
1338
1339    /// Handle user confirmation for checkout operations with uncommitted changes
1340    fn handle_checkout_confirmation(
1341        &self,
1342        target: &str,
1343        safety_info: &CheckoutSafety,
1344    ) -> Result<()> {
1345        // Check if we're in a non-interactive environment FIRST (before any output)
1346        let is_ci = std::env::var("CI").is_ok();
1347        let no_confirm = std::env::var("CHECKOUT_NO_CONFIRM").is_ok();
1348        let is_non_interactive = is_ci || no_confirm;
1349
1350        if is_non_interactive {
1351            return Err(CascadeError::branch(
1352                format!(
1353                    "Cannot checkout '{target}' with uncommitted changes in non-interactive mode. Commit your changes or use stash first."
1354                )
1355            ));
1356        }
1357
1358        // Interactive warning and confirmation
1359        println!("\n⚠️  CHECKOUT WARNING ⚠️");
1360        println!("You have uncommitted changes that could be lost:");
1361
1362        if !safety_info.modified_files.is_empty() {
1363            println!(
1364                "\n📝 Modified files ({}):",
1365                safety_info.modified_files.len()
1366            );
1367            for file in safety_info.modified_files.iter().take(10) {
1368                println!("   - {file}");
1369            }
1370            if safety_info.modified_files.len() > 10 {
1371                println!("   ... and {} more", safety_info.modified_files.len() - 10);
1372            }
1373        }
1374
1375        if !safety_info.staged_files.is_empty() {
1376            println!("\n📁 Staged files ({}):", safety_info.staged_files.len());
1377            for file in safety_info.staged_files.iter().take(10) {
1378                println!("   - {file}");
1379            }
1380            if safety_info.staged_files.len() > 10 {
1381                println!("   ... and {} more", safety_info.staged_files.len() - 10);
1382            }
1383        }
1384
1385        if !safety_info.untracked_files.is_empty() {
1386            println!(
1387                "\n❓ Untracked files ({}):",
1388                safety_info.untracked_files.len()
1389            );
1390            for file in safety_info.untracked_files.iter().take(5) {
1391                println!("   - {file}");
1392            }
1393            if safety_info.untracked_files.len() > 5 {
1394                println!("   ... and {} more", safety_info.untracked_files.len() - 5);
1395            }
1396        }
1397
1398        println!("\n🔄 Options:");
1399        println!("1. Stash changes and checkout (recommended)");
1400        println!("2. Force checkout (WILL LOSE UNCOMMITTED CHANGES)");
1401        println!("3. Cancel checkout");
1402
1403        let confirmation = Confirm::with_theme(&ColorfulTheme::default())
1404            .with_prompt("Would you like to stash your changes and proceed with checkout?")
1405            .interact()
1406            .map_err(|e| CascadeError::branch(format!("Could not get user confirmation: {e}")))?;
1407
1408        if confirmation {
1409            // Create stash before checkout
1410            let stash_message = format!(
1411                "Auto-stash before checkout to {} at {}",
1412                target,
1413                chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
1414            );
1415
1416            match self.create_stash(&stash_message) {
1417                Ok(stash_oid) => {
1418                    println!("✅ Created stash: {stash_message} ({stash_oid})");
1419                    println!("💡 You can restore with: git stash pop");
1420                }
1421                Err(e) => {
1422                    println!("❌ Failed to create stash: {e}");
1423
1424                    let force_confirm = Confirm::with_theme(&ColorfulTheme::default())
1425                        .with_prompt("Stash failed. Force checkout anyway? (WILL LOSE CHANGES)")
1426                        .interact()
1427                        .map_err(|e| {
1428                            CascadeError::branch(format!("Could not get confirmation: {e}"))
1429                        })?;
1430
1431                    if !force_confirm {
1432                        return Err(CascadeError::branch(
1433                            "Checkout cancelled by user".to_string(),
1434                        ));
1435                    }
1436                }
1437            }
1438        } else {
1439            return Err(CascadeError::branch(
1440                "Checkout cancelled by user".to_string(),
1441            ));
1442        }
1443
1444        Ok(())
1445    }
1446
1447    /// Create a stash with uncommitted changes
1448    fn create_stash(&self, message: &str) -> Result<String> {
1449        // For now, we'll use a different approach that doesn't require mutable access
1450        // This is a simplified version that recommends manual stashing
1451
1452        warn!("Automatic stashing not yet implemented - please stash manually");
1453        Err(CascadeError::branch(format!(
1454            "Please manually stash your changes first: git stash push -m \"{message}\""
1455        )))
1456    }
1457
1458    /// Get modified files in working directory
1459    fn get_modified_files(&self) -> Result<Vec<String>> {
1460        let mut opts = git2::StatusOptions::new();
1461        opts.include_untracked(false).include_ignored(false);
1462
1463        let statuses = self
1464            .repo
1465            .statuses(Some(&mut opts))
1466            .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
1467
1468        let mut modified_files = Vec::new();
1469        for status in statuses.iter() {
1470            let flags = status.status();
1471            if flags.contains(git2::Status::WT_MODIFIED) || flags.contains(git2::Status::WT_DELETED)
1472            {
1473                if let Some(path) = status.path() {
1474                    modified_files.push(path.to_string());
1475                }
1476            }
1477        }
1478
1479        Ok(modified_files)
1480    }
1481
1482    /// Get staged files in index
1483    fn get_staged_files(&self) -> Result<Vec<String>> {
1484        let mut opts = git2::StatusOptions::new();
1485        opts.include_untracked(false).include_ignored(false);
1486
1487        let statuses = self
1488            .repo
1489            .statuses(Some(&mut opts))
1490            .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
1491
1492        let mut staged_files = Vec::new();
1493        for status in statuses.iter() {
1494            let flags = status.status();
1495            if flags.contains(git2::Status::INDEX_MODIFIED)
1496                || flags.contains(git2::Status::INDEX_NEW)
1497                || flags.contains(git2::Status::INDEX_DELETED)
1498            {
1499                if let Some(path) = status.path() {
1500                    staged_files.push(path.to_string());
1501                }
1502            }
1503        }
1504
1505        Ok(staged_files)
1506    }
1507
1508    /// Count commits between two references
1509    fn count_commits_between(&self, from: &str, to: &str) -> Result<usize> {
1510        let commits = self.get_commits_between(from, to)?;
1511        Ok(commits.len())
1512    }
1513
1514    /// Resolve a reference (branch name, tag, or commit hash) to a commit
1515    pub fn resolve_reference(&self, reference: &str) -> Result<git2::Commit<'_>> {
1516        // Try to parse as commit hash first
1517        if let Ok(oid) = Oid::from_str(reference) {
1518            if let Ok(commit) = self.repo.find_commit(oid) {
1519                return Ok(commit);
1520            }
1521        }
1522
1523        // Try to resolve as a reference (branch, tag, etc.)
1524        let obj = self.repo.revparse_single(reference).map_err(|e| {
1525            CascadeError::branch(format!("Could not resolve reference '{reference}': {e}"))
1526        })?;
1527
1528        obj.peel_to_commit().map_err(|e| {
1529            CascadeError::branch(format!(
1530                "Reference '{reference}' does not point to a commit: {e}"
1531            ))
1532        })
1533    }
1534
1535    /// Reset HEAD to a specific reference (soft reset)
1536    pub fn reset_soft(&self, target_ref: &str) -> Result<()> {
1537        let target_commit = self.resolve_reference(target_ref)?;
1538
1539        self.repo
1540            .reset(target_commit.as_object(), git2::ResetType::Soft, None)
1541            .map_err(CascadeError::Git)?;
1542
1543        Ok(())
1544    }
1545
1546    /// Find which branch contains a specific commit
1547    pub fn find_branch_containing_commit(&self, commit_hash: &str) -> Result<String> {
1548        let oid = Oid::from_str(commit_hash).map_err(|e| {
1549            CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
1550        })?;
1551
1552        // Get all local branches
1553        let branches = self
1554            .repo
1555            .branches(Some(git2::BranchType::Local))
1556            .map_err(CascadeError::Git)?;
1557
1558        for branch_result in branches {
1559            let (branch, _) = branch_result.map_err(CascadeError::Git)?;
1560
1561            if let Some(branch_name) = branch.name().map_err(CascadeError::Git)? {
1562                // Check if this branch contains the commit
1563                if let Ok(branch_head) = branch.get().peel_to_commit() {
1564                    // Walk the commit history from this branch's HEAD
1565                    let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
1566                    revwalk.push(branch_head.id()).map_err(CascadeError::Git)?;
1567
1568                    for commit_oid in revwalk {
1569                        let commit_oid = commit_oid.map_err(CascadeError::Git)?;
1570                        if commit_oid == oid {
1571                            return Ok(branch_name.to_string());
1572                        }
1573                    }
1574                }
1575            }
1576        }
1577
1578        // If not found in any branch, might be on current HEAD
1579        Err(CascadeError::branch(format!(
1580            "Commit {commit_hash} not found in any local branch"
1581        )))
1582    }
1583
1584    // Async wrappers for potentially blocking operations
1585
1586    /// Fetch from remote origin (async)
1587    pub async fn fetch_async(&self) -> Result<()> {
1588        let repo_path = self.path.clone();
1589        crate::utils::async_ops::run_git_operation(move || {
1590            let repo = GitRepository::open(&repo_path)?;
1591            repo.fetch()
1592        })
1593        .await
1594    }
1595
1596    /// Pull changes from remote (async)
1597    pub async fn pull_async(&self, branch: &str) -> Result<()> {
1598        let repo_path = self.path.clone();
1599        let branch_name = branch.to_string();
1600        crate::utils::async_ops::run_git_operation(move || {
1601            let repo = GitRepository::open(&repo_path)?;
1602            repo.pull(&branch_name)
1603        })
1604        .await
1605    }
1606
1607    /// Push branch to remote (async)
1608    pub async fn push_branch_async(&self, branch_name: &str) -> Result<()> {
1609        let repo_path = self.path.clone();
1610        let branch = branch_name.to_string();
1611        crate::utils::async_ops::run_git_operation(move || {
1612            let repo = GitRepository::open(&repo_path)?;
1613            repo.push(&branch)
1614        })
1615        .await
1616    }
1617
1618    /// Cherry-pick commit (async)
1619    pub async fn cherry_pick_commit_async(&self, commit_hash: &str) -> Result<String> {
1620        let repo_path = self.path.clone();
1621        let hash = commit_hash.to_string();
1622        crate::utils::async_ops::run_git_operation(move || {
1623            let repo = GitRepository::open(&repo_path)?;
1624            repo.cherry_pick(&hash)
1625        })
1626        .await
1627    }
1628
1629    /// Get commit hashes between two refs (async)
1630    pub async fn get_commit_hashes_between_async(
1631        &self,
1632        from: &str,
1633        to: &str,
1634    ) -> Result<Vec<String>> {
1635        let repo_path = self.path.clone();
1636        let from_str = from.to_string();
1637        let to_str = to.to_string();
1638        crate::utils::async_ops::run_git_operation(move || {
1639            let repo = GitRepository::open(&repo_path)?;
1640            let commits = repo.get_commits_between(&from_str, &to_str)?;
1641            Ok(commits.into_iter().map(|c| c.id().to_string()).collect())
1642        })
1643        .await
1644    }
1645}
1646
1647#[cfg(test)]
1648mod tests {
1649    use super::*;
1650    use std::process::Command;
1651    use tempfile::TempDir;
1652
1653    fn create_test_repo() -> (TempDir, PathBuf) {
1654        let temp_dir = TempDir::new().unwrap();
1655        let repo_path = temp_dir.path().to_path_buf();
1656
1657        // Initialize git repository
1658        Command::new("git")
1659            .args(["init"])
1660            .current_dir(&repo_path)
1661            .output()
1662            .unwrap();
1663        Command::new("git")
1664            .args(["config", "user.name", "Test"])
1665            .current_dir(&repo_path)
1666            .output()
1667            .unwrap();
1668        Command::new("git")
1669            .args(["config", "user.email", "test@test.com"])
1670            .current_dir(&repo_path)
1671            .output()
1672            .unwrap();
1673
1674        // Create initial commit
1675        std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
1676        Command::new("git")
1677            .args(["add", "."])
1678            .current_dir(&repo_path)
1679            .output()
1680            .unwrap();
1681        Command::new("git")
1682            .args(["commit", "-m", "Initial commit"])
1683            .current_dir(&repo_path)
1684            .output()
1685            .unwrap();
1686
1687        (temp_dir, repo_path)
1688    }
1689
1690    fn create_commit(repo_path: &PathBuf, message: &str, filename: &str) {
1691        let file_path = repo_path.join(filename);
1692        std::fs::write(&file_path, format!("Content for {filename}\n")).unwrap();
1693
1694        Command::new("git")
1695            .args(["add", filename])
1696            .current_dir(repo_path)
1697            .output()
1698            .unwrap();
1699        Command::new("git")
1700            .args(["commit", "-m", message])
1701            .current_dir(repo_path)
1702            .output()
1703            .unwrap();
1704    }
1705
1706    #[test]
1707    fn test_repository_info() {
1708        let (_temp_dir, repo_path) = create_test_repo();
1709        let repo = GitRepository::open(&repo_path).unwrap();
1710
1711        let info = repo.get_info().unwrap();
1712        assert!(!info.is_dirty); // Should be clean after commit
1713        assert!(
1714            info.head_branch == Some("master".to_string())
1715                || info.head_branch == Some("main".to_string()),
1716            "Expected default branch to be 'master' or 'main', got {:?}",
1717            info.head_branch
1718        );
1719        assert!(info.head_commit.is_some()); // Just check it exists
1720        assert!(info.untracked_files.is_empty()); // Should be empty after commit
1721    }
1722
1723    #[test]
1724    fn test_force_push_branch_basic() {
1725        let (_temp_dir, repo_path) = create_test_repo();
1726        let repo = GitRepository::open(&repo_path).unwrap();
1727
1728        // Get the actual default branch name
1729        let default_branch = repo.get_current_branch().unwrap();
1730
1731        // Create source branch with commits
1732        create_commit(&repo_path, "Feature commit 1", "feature1.rs");
1733        Command::new("git")
1734            .args(["checkout", "-b", "source-branch"])
1735            .current_dir(&repo_path)
1736            .output()
1737            .unwrap();
1738        create_commit(&repo_path, "Feature commit 2", "feature2.rs");
1739
1740        // Create target branch
1741        Command::new("git")
1742            .args(["checkout", &default_branch])
1743            .current_dir(&repo_path)
1744            .output()
1745            .unwrap();
1746        Command::new("git")
1747            .args(["checkout", "-b", "target-branch"])
1748            .current_dir(&repo_path)
1749            .output()
1750            .unwrap();
1751        create_commit(&repo_path, "Target commit", "target.rs");
1752
1753        // Test force push from source to target
1754        let result = repo.force_push_branch("target-branch", "source-branch");
1755
1756        // Should succeed in test environment (even though it doesn't actually push to remote)
1757        // The important thing is that the function doesn't panic and handles the git2 operations
1758        assert!(result.is_ok() || result.is_err()); // Either is acceptable for unit test
1759    }
1760
1761    #[test]
1762    fn test_force_push_branch_nonexistent_branches() {
1763        let (_temp_dir, repo_path) = create_test_repo();
1764        let repo = GitRepository::open(&repo_path).unwrap();
1765
1766        // Get the actual default branch name
1767        let default_branch = repo.get_current_branch().unwrap();
1768
1769        // Test force push with nonexistent source branch
1770        let result = repo.force_push_branch("target", "nonexistent-source");
1771        assert!(result.is_err());
1772
1773        // Test force push with nonexistent target branch
1774        let result = repo.force_push_branch("nonexistent-target", &default_branch);
1775        assert!(result.is_err());
1776    }
1777
1778    #[test]
1779    fn test_force_push_workflow_simulation() {
1780        let (_temp_dir, repo_path) = create_test_repo();
1781        let repo = GitRepository::open(&repo_path).unwrap();
1782
1783        // Simulate the smart force push workflow:
1784        // 1. Original branch exists with PR
1785        Command::new("git")
1786            .args(["checkout", "-b", "feature-auth"])
1787            .current_dir(&repo_path)
1788            .output()
1789            .unwrap();
1790        create_commit(&repo_path, "Add authentication", "auth.rs");
1791
1792        // 2. Rebase creates versioned branch
1793        Command::new("git")
1794            .args(["checkout", "-b", "feature-auth-v2"])
1795            .current_dir(&repo_path)
1796            .output()
1797            .unwrap();
1798        create_commit(&repo_path, "Fix auth validation", "auth.rs");
1799
1800        // 3. Smart force push: update original branch from versioned branch
1801        let result = repo.force_push_branch("feature-auth", "feature-auth-v2");
1802
1803        // Verify the operation is handled properly (success or expected error)
1804        match result {
1805            Ok(_) => {
1806                // Force push succeeded - verify branch state if possible
1807                Command::new("git")
1808                    .args(["checkout", "feature-auth"])
1809                    .current_dir(&repo_path)
1810                    .output()
1811                    .unwrap();
1812                let log_output = Command::new("git")
1813                    .args(["log", "--oneline", "-2"])
1814                    .current_dir(&repo_path)
1815                    .output()
1816                    .unwrap();
1817                let log_str = String::from_utf8_lossy(&log_output.stdout);
1818                assert!(
1819                    log_str.contains("Fix auth validation")
1820                        || log_str.contains("Add authentication")
1821                );
1822            }
1823            Err(_) => {
1824                // Expected in test environment without remote - that's fine
1825                // The important thing is we tested the code path without panicking
1826            }
1827        }
1828    }
1829
1830    #[test]
1831    fn test_branch_operations() {
1832        let (_temp_dir, repo_path) = create_test_repo();
1833        let repo = GitRepository::open(&repo_path).unwrap();
1834
1835        // Test get current branch - accept either main or master
1836        let current = repo.get_current_branch().unwrap();
1837        assert!(
1838            current == "master" || current == "main",
1839            "Expected default branch to be 'master' or 'main', got '{current}'"
1840        );
1841
1842        // Test create branch
1843        Command::new("git")
1844            .args(["checkout", "-b", "test-branch"])
1845            .current_dir(&repo_path)
1846            .output()
1847            .unwrap();
1848        let current = repo.get_current_branch().unwrap();
1849        assert_eq!(current, "test-branch");
1850    }
1851
1852    #[test]
1853    fn test_commit_operations() {
1854        let (_temp_dir, repo_path) = create_test_repo();
1855        let repo = GitRepository::open(&repo_path).unwrap();
1856
1857        // Test get head commit
1858        let head = repo.get_head_commit().unwrap();
1859        assert_eq!(head.message().unwrap().trim(), "Initial commit");
1860
1861        // Test get commit by hash
1862        let hash = head.id().to_string();
1863        let same_commit = repo.get_commit(&hash).unwrap();
1864        assert_eq!(head.id(), same_commit.id());
1865    }
1866
1867    #[test]
1868    fn test_checkout_safety_clean_repo() {
1869        let (_temp_dir, repo_path) = create_test_repo();
1870        let repo = GitRepository::open(&repo_path).unwrap();
1871
1872        // Create a test branch
1873        create_commit(&repo_path, "Second commit", "test.txt");
1874        Command::new("git")
1875            .args(["checkout", "-b", "test-branch"])
1876            .current_dir(&repo_path)
1877            .output()
1878            .unwrap();
1879
1880        // Test checkout safety with clean repo
1881        let safety_result = repo.check_checkout_safety("main");
1882        assert!(safety_result.is_ok());
1883        assert!(safety_result.unwrap().is_none()); // Clean repo should return None
1884    }
1885
1886    #[test]
1887    fn test_checkout_safety_with_modified_files() {
1888        let (_temp_dir, repo_path) = create_test_repo();
1889        let repo = GitRepository::open(&repo_path).unwrap();
1890
1891        // Create a test branch
1892        Command::new("git")
1893            .args(["checkout", "-b", "test-branch"])
1894            .current_dir(&repo_path)
1895            .output()
1896            .unwrap();
1897
1898        // Modify a file to create uncommitted changes
1899        std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
1900
1901        // Test checkout safety with modified files
1902        let safety_result = repo.check_checkout_safety("main");
1903        assert!(safety_result.is_ok());
1904        let safety_info = safety_result.unwrap();
1905        assert!(safety_info.is_some());
1906
1907        let info = safety_info.unwrap();
1908        assert!(!info.modified_files.is_empty());
1909        assert!(info.modified_files.contains(&"README.md".to_string()));
1910    }
1911
1912    #[test]
1913    fn test_unsafe_checkout_methods() {
1914        let (_temp_dir, repo_path) = create_test_repo();
1915        let repo = GitRepository::open(&repo_path).unwrap();
1916
1917        // Create a test branch
1918        create_commit(&repo_path, "Second commit", "test.txt");
1919        Command::new("git")
1920            .args(["checkout", "-b", "test-branch"])
1921            .current_dir(&repo_path)
1922            .output()
1923            .unwrap();
1924
1925        // Modify a file to create uncommitted changes
1926        std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
1927
1928        // Test unsafe checkout methods bypass safety checks
1929        let _result = repo.checkout_branch_unsafe("master");
1930        // Note: This might still fail due to git2 restrictions, but shouldn't hit our safety code
1931        // The important thing is that it doesn't trigger our safety confirmation
1932
1933        // Test unsafe commit checkout
1934        let head_commit = repo.get_head_commit().unwrap();
1935        let commit_hash = head_commit.id().to_string();
1936        let _result = repo.checkout_commit_unsafe(&commit_hash);
1937        // Similar to above - testing that safety is bypassed
1938    }
1939
1940    #[test]
1941    fn test_get_modified_files() {
1942        let (_temp_dir, repo_path) = create_test_repo();
1943        let repo = GitRepository::open(&repo_path).unwrap();
1944
1945        // Initially should have no modified files
1946        let modified = repo.get_modified_files().unwrap();
1947        assert!(modified.is_empty());
1948
1949        // Modify a file
1950        std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
1951
1952        // Should now detect the modified file
1953        let modified = repo.get_modified_files().unwrap();
1954        assert_eq!(modified.len(), 1);
1955        assert!(modified.contains(&"README.md".to_string()));
1956    }
1957
1958    #[test]
1959    fn test_get_staged_files() {
1960        let (_temp_dir, repo_path) = create_test_repo();
1961        let repo = GitRepository::open(&repo_path).unwrap();
1962
1963        // Initially should have no staged files
1964        let staged = repo.get_staged_files().unwrap();
1965        assert!(staged.is_empty());
1966
1967        // Create and stage a new file
1968        std::fs::write(repo_path.join("staged.txt"), "Staged content").unwrap();
1969        Command::new("git")
1970            .args(["add", "staged.txt"])
1971            .current_dir(&repo_path)
1972            .output()
1973            .unwrap();
1974
1975        // Should now detect the staged file
1976        let staged = repo.get_staged_files().unwrap();
1977        assert_eq!(staged.len(), 1);
1978        assert!(staged.contains(&"staged.txt".to_string()));
1979    }
1980
1981    #[test]
1982    fn test_create_stash_fallback() {
1983        let (_temp_dir, repo_path) = create_test_repo();
1984        let repo = GitRepository::open(&repo_path).unwrap();
1985
1986        // Test that stash creation returns helpful error message
1987        let result = repo.create_stash("test stash");
1988        assert!(result.is_err());
1989        let error_msg = result.unwrap_err().to_string();
1990        assert!(error_msg.contains("git stash push"));
1991    }
1992
1993    #[test]
1994    fn test_delete_branch_unsafe() {
1995        let (_temp_dir, repo_path) = create_test_repo();
1996        let repo = GitRepository::open(&repo_path).unwrap();
1997
1998        // Create a test branch
1999        create_commit(&repo_path, "Second commit", "test.txt");
2000        Command::new("git")
2001            .args(["checkout", "-b", "test-branch"])
2002            .current_dir(&repo_path)
2003            .output()
2004            .unwrap();
2005
2006        // Add another commit to the test branch to make it different from master
2007        create_commit(&repo_path, "Branch-specific commit", "branch.txt");
2008
2009        // Go back to master
2010        Command::new("git")
2011            .args(["checkout", "master"])
2012            .current_dir(&repo_path)
2013            .output()
2014            .unwrap();
2015
2016        // Test unsafe delete bypasses safety checks
2017        // Note: This may still fail if the branch has unpushed commits, but it should bypass our safety confirmation
2018        let result = repo.delete_branch_unsafe("test-branch");
2019        // Even if it fails, the key is that it didn't prompt for user confirmation
2020        // So we just check that it attempted the operation without interactive prompts
2021        let _ = result; // Don't assert success since delete may fail for git reasons
2022    }
2023
2024    #[test]
2025    fn test_force_push_unsafe() {
2026        let (_temp_dir, repo_path) = create_test_repo();
2027        let repo = GitRepository::open(&repo_path).unwrap();
2028
2029        // Create a test branch
2030        create_commit(&repo_path, "Second commit", "test.txt");
2031        Command::new("git")
2032            .args(["checkout", "-b", "test-branch"])
2033            .current_dir(&repo_path)
2034            .output()
2035            .unwrap();
2036
2037        // Test unsafe force push bypasses safety checks
2038        // Note: This will likely fail due to no remote, but it tests the safety bypass
2039        let _result = repo.force_push_branch_unsafe("test-branch", "test-branch");
2040        // The key is that it doesn't trigger safety confirmation dialogs
2041    }
2042}