cascade_cli/git/
repository.rs

1use crate::cli::output::Output;
2use crate::errors::{CascadeError, Result};
3use chrono;
4use dialoguer::{theme::ColorfulTheme, Confirm, Select};
5use git2::{Oid, Repository, Signature};
6use std::path::{Path, PathBuf};
7use tracing::{info, warn};
8
9/// Repository information
10#[derive(Debug, Clone)]
11pub struct RepositoryInfo {
12    pub path: PathBuf,
13    pub head_branch: Option<String>,
14    pub head_commit: Option<String>,
15    pub is_dirty: bool,
16    pub untracked_files: Vec<String>,
17}
18
19/// Backup information for force push operations
20#[derive(Debug, Clone)]
21struct ForceBackupInfo {
22    pub backup_branch_name: String,
23    pub remote_commit_id: String,
24    #[allow(dead_code)] // Used for logging/display purposes
25    pub commits_that_would_be_lost: usize,
26}
27
28/// Safety information for branch deletion operations
29#[derive(Debug, Clone)]
30struct BranchDeletionSafety {
31    pub unpushed_commits: Vec<String>,
32    pub remote_tracking_branch: Option<String>,
33    pub is_merged_to_main: bool,
34    pub main_branch_name: String,
35}
36
37/// Safety information for checkout operations
38#[derive(Debug, Clone)]
39struct CheckoutSafety {
40    #[allow(dead_code)] // Used in confirmation dialogs and future features
41    pub has_uncommitted_changes: bool,
42    pub modified_files: Vec<String>,
43    pub staged_files: Vec<String>,
44    pub untracked_files: Vec<String>,
45    #[allow(dead_code)] // Reserved for future automatic stashing implementation
46    pub stash_created: Option<String>,
47    #[allow(dead_code)] // Used for context in confirmation dialogs
48    pub current_branch: Option<String>,
49}
50
51/// SSL configuration for git operations
52#[derive(Debug, Clone)]
53pub struct GitSslConfig {
54    pub accept_invalid_certs: bool,
55    pub ca_bundle_path: Option<String>,
56}
57
58/// Wrapper around git2::Repository with safe operations
59///
60/// For thread safety, use the async variants (e.g., fetch_async, pull_async)
61/// which automatically handle threading using tokio::spawn_blocking.
62/// The async methods create new repository instances in background threads.
63pub struct GitRepository {
64    repo: Repository,
65    path: PathBuf,
66    ssl_config: Option<GitSslConfig>,
67    bitbucket_credentials: Option<BitbucketCredentials>,
68}
69
70#[derive(Debug, Clone)]
71struct BitbucketCredentials {
72    username: Option<String>,
73    token: Option<String>,
74}
75
76impl GitRepository {
77    /// Open a Git repository at the given path
78    /// Automatically loads SSL configuration from cascade config if available
79    pub fn open(path: &Path) -> Result<Self> {
80        let repo = Repository::discover(path)
81            .map_err(|e| CascadeError::config(format!("Not a git repository: {e}")))?;
82
83        let workdir = repo
84            .workdir()
85            .ok_or_else(|| CascadeError::config("Repository has no working directory"))?
86            .to_path_buf();
87
88        // Try to load SSL configuration from cascade config
89        let ssl_config = Self::load_ssl_config_from_cascade(&workdir);
90        let bitbucket_credentials = Self::load_bitbucket_credentials_from_cascade(&workdir);
91
92        Ok(Self {
93            repo,
94            path: workdir,
95            ssl_config,
96            bitbucket_credentials,
97        })
98    }
99
100    /// Load SSL configuration from cascade config file if it exists
101    fn load_ssl_config_from_cascade(repo_path: &Path) -> Option<GitSslConfig> {
102        // Try to load cascade configuration
103        let config_dir = crate::config::get_repo_config_dir(repo_path).ok()?;
104        let config_path = config_dir.join("config.json");
105        let settings = crate::config::Settings::load_from_file(&config_path).ok()?;
106
107        // Convert BitbucketConfig to GitSslConfig if SSL settings exist
108        if settings.bitbucket.accept_invalid_certs.is_some()
109            || settings.bitbucket.ca_bundle_path.is_some()
110        {
111            Some(GitSslConfig {
112                accept_invalid_certs: settings.bitbucket.accept_invalid_certs.unwrap_or(false),
113                ca_bundle_path: settings.bitbucket.ca_bundle_path,
114            })
115        } else {
116            None
117        }
118    }
119
120    /// Load Bitbucket credentials from cascade config file if it exists
121    fn load_bitbucket_credentials_from_cascade(repo_path: &Path) -> Option<BitbucketCredentials> {
122        // Try to load cascade configuration
123        let config_dir = crate::config::get_repo_config_dir(repo_path).ok()?;
124        let config_path = config_dir.join("config.json");
125        let settings = crate::config::Settings::load_from_file(&config_path).ok()?;
126
127        // Return credentials if any are configured
128        if settings.bitbucket.username.is_some() || settings.bitbucket.token.is_some() {
129            Some(BitbucketCredentials {
130                username: settings.bitbucket.username.clone(),
131                token: settings.bitbucket.token.clone(),
132            })
133        } else {
134            None
135        }
136    }
137
138    /// Get repository information
139    pub fn get_info(&self) -> Result<RepositoryInfo> {
140        let head_branch = self.get_current_branch().ok();
141        let head_commit = self.get_head_commit_hash().ok();
142        let is_dirty = self.is_dirty()?;
143        let untracked_files = self.get_untracked_files()?;
144
145        Ok(RepositoryInfo {
146            path: self.path.clone(),
147            head_branch,
148            head_commit,
149            is_dirty,
150            untracked_files,
151        })
152    }
153
154    /// Get the current branch name
155    pub fn get_current_branch(&self) -> Result<String> {
156        let head = self
157            .repo
158            .head()
159            .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
160
161        if let Some(name) = head.shorthand() {
162            Ok(name.to_string())
163        } else {
164            // Detached HEAD - return commit hash
165            let commit = head
166                .peel_to_commit()
167                .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))?;
168            Ok(format!("HEAD@{}", commit.id()))
169        }
170    }
171
172    /// Get the HEAD commit hash
173    pub fn get_head_commit_hash(&self) -> Result<String> {
174        let head = self
175            .repo
176            .head()
177            .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
178
179        let commit = head
180            .peel_to_commit()
181            .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))?;
182
183        Ok(commit.id().to_string())
184    }
185
186    /// Check if the working directory is dirty (has uncommitted changes)
187    pub fn is_dirty(&self) -> Result<bool> {
188        let statuses = self.repo.statuses(None).map_err(CascadeError::Git)?;
189
190        for status in statuses.iter() {
191            let flags = status.status();
192
193            // Check for any modifications, additions, or deletions
194            if flags.intersects(
195                git2::Status::INDEX_MODIFIED
196                    | git2::Status::INDEX_NEW
197                    | git2::Status::INDEX_DELETED
198                    | git2::Status::WT_MODIFIED
199                    | git2::Status::WT_NEW
200                    | git2::Status::WT_DELETED,
201            ) {
202                return Ok(true);
203            }
204        }
205
206        Ok(false)
207    }
208
209    /// Get list of untracked files
210    pub fn get_untracked_files(&self) -> Result<Vec<String>> {
211        let statuses = self.repo.statuses(None).map_err(CascadeError::Git)?;
212
213        let mut untracked = Vec::new();
214        for status in statuses.iter() {
215            if status.status().contains(git2::Status::WT_NEW) {
216                if let Some(path) = status.path() {
217                    untracked.push(path.to_string());
218                }
219            }
220        }
221
222        Ok(untracked)
223    }
224
225    /// Create a new branch
226    pub fn create_branch(&self, name: &str, target: Option<&str>) -> Result<()> {
227        let target_commit = if let Some(target) = target {
228            // Find the specified target commit/branch
229            let target_obj = self.repo.revparse_single(target).map_err(|e| {
230                CascadeError::branch(format!("Could not find target '{target}': {e}"))
231            })?;
232            target_obj.peel_to_commit().map_err(|e| {
233                CascadeError::branch(format!("Target '{target}' is not a commit: {e}"))
234            })?
235        } else {
236            // Use current HEAD
237            let head = self
238                .repo
239                .head()
240                .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
241            head.peel_to_commit()
242                .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))?
243        };
244
245        self.repo
246            .branch(name, &target_commit, false)
247            .map_err(|e| CascadeError::branch(format!("Could not create branch '{name}': {e}")))?;
248
249        // Branch creation logging is handled by the caller for clean output
250        Ok(())
251    }
252
253    /// Switch to a branch with safety checks
254    pub fn checkout_branch(&self, name: &str) -> Result<()> {
255        self.checkout_branch_with_options(name, false)
256    }
257
258    /// Switch to a branch with force option to bypass safety checks
259    pub fn checkout_branch_unsafe(&self, name: &str) -> Result<()> {
260        self.checkout_branch_with_options(name, true)
261    }
262
263    /// Internal branch checkout implementation with safety options
264    fn checkout_branch_with_options(&self, name: &str, force_unsafe: bool) -> Result<()> {
265        info!("Attempting to checkout branch: {}", name);
266
267        // Enhanced safety check: Detect uncommitted work before checkout
268        if !force_unsafe {
269            let safety_result = self.check_checkout_safety(name)?;
270            if let Some(safety_info) = safety_result {
271                // Repository has uncommitted changes, get user confirmation
272                self.handle_checkout_confirmation(name, &safety_info)?;
273            }
274        }
275
276        // Find the branch
277        let branch = self
278            .repo
279            .find_branch(name, git2::BranchType::Local)
280            .map_err(|e| CascadeError::branch(format!("Could not find branch '{name}': {e}")))?;
281
282        let branch_ref = branch.get();
283        let tree = branch_ref.peel_to_tree().map_err(|e| {
284            CascadeError::branch(format!("Could not get tree for branch '{name}': {e}"))
285        })?;
286
287        // Checkout the tree
288        self.repo
289            .checkout_tree(tree.as_object(), None)
290            .map_err(|e| {
291                CascadeError::branch(format!("Could not checkout branch '{name}': {e}"))
292            })?;
293
294        // Update HEAD
295        self.repo
296            .set_head(&format!("refs/heads/{name}"))
297            .map_err(|e| CascadeError::branch(format!("Could not update HEAD to '{name}': {e}")))?;
298
299        Output::success(format!("Switched to branch '{name}'"));
300        Ok(())
301    }
302
303    /// Checkout a specific commit (detached HEAD) with safety checks
304    pub fn checkout_commit(&self, commit_hash: &str) -> Result<()> {
305        self.checkout_commit_with_options(commit_hash, false)
306    }
307
308    /// Checkout a specific commit with force option to bypass safety checks
309    pub fn checkout_commit_unsafe(&self, commit_hash: &str) -> Result<()> {
310        self.checkout_commit_with_options(commit_hash, true)
311    }
312
313    /// Internal commit checkout implementation with safety options
314    fn checkout_commit_with_options(&self, commit_hash: &str, force_unsafe: bool) -> Result<()> {
315        info!("Attempting to checkout commit: {}", commit_hash);
316
317        // Enhanced safety check: Detect uncommitted work before checkout
318        if !force_unsafe {
319            let safety_result = self.check_checkout_safety(&format!("commit:{commit_hash}"))?;
320            if let Some(safety_info) = safety_result {
321                // Repository has uncommitted changes, get user confirmation
322                self.handle_checkout_confirmation(&format!("commit {commit_hash}"), &safety_info)?;
323            }
324        }
325
326        let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
327
328        let commit = self.repo.find_commit(oid).map_err(|e| {
329            CascadeError::branch(format!("Could not find commit '{commit_hash}': {e}"))
330        })?;
331
332        let tree = commit.tree().map_err(|e| {
333            CascadeError::branch(format!(
334                "Could not get tree for commit '{commit_hash}': {e}"
335            ))
336        })?;
337
338        // Checkout the tree
339        self.repo
340            .checkout_tree(tree.as_object(), None)
341            .map_err(|e| {
342                CascadeError::branch(format!("Could not checkout commit '{commit_hash}': {e}"))
343            })?;
344
345        // Update HEAD to the commit (detached HEAD)
346        self.repo.set_head_detached(oid).map_err(|e| {
347            CascadeError::branch(format!(
348                "Could not update HEAD to commit '{commit_hash}': {e}"
349            ))
350        })?;
351
352        Output::success(format!(
353            "Checked out commit '{commit_hash}' (detached HEAD)"
354        ));
355        Ok(())
356    }
357
358    /// Check if a branch exists
359    pub fn branch_exists(&self, name: &str) -> bool {
360        self.repo.find_branch(name, git2::BranchType::Local).is_ok()
361    }
362
363    /// Check if a branch exists locally, and if not, attempt to fetch it from remote
364    pub fn branch_exists_or_fetch(&self, name: &str) -> Result<bool> {
365        // 1. Check if branch exists locally first
366        if self.repo.find_branch(name, git2::BranchType::Local).is_ok() {
367            return Ok(true);
368        }
369
370        // 2. Try to fetch it from remote
371        println!("🔍 Branch '{name}' not found locally, trying to fetch from remote...");
372
373        use std::process::Command;
374
375        // Try: git fetch origin release/12.34:release/12.34
376        let fetch_result = Command::new("git")
377            .args(["fetch", "origin", &format!("{name}:{name}")])
378            .current_dir(&self.path)
379            .output();
380
381        match fetch_result {
382            Ok(output) => {
383                if output.status.success() {
384                    println!("✅ Successfully fetched '{name}' from origin");
385                    // 3. Check again locally after fetch
386                    return Ok(self.repo.find_branch(name, git2::BranchType::Local).is_ok());
387                } else {
388                    let stderr = String::from_utf8_lossy(&output.stderr);
389                    tracing::debug!("Failed to fetch branch '{name}': {stderr}");
390                }
391            }
392            Err(e) => {
393                tracing::debug!("Git fetch command failed: {e}");
394            }
395        }
396
397        // 4. Try alternative fetch patterns for common branch naming
398        if name.contains('/') {
399            println!("🔍 Trying alternative fetch patterns...");
400
401            // Try: git fetch origin (to get all refs, then checkout locally)
402            let fetch_all_result = Command::new("git")
403                .args(["fetch", "origin"])
404                .current_dir(&self.path)
405                .output();
406
407            if let Ok(output) = fetch_all_result {
408                if output.status.success() {
409                    // Try to create local branch from remote
410                    let checkout_result = Command::new("git")
411                        .args(["checkout", "-b", name, &format!("origin/{name}")])
412                        .current_dir(&self.path)
413                        .output();
414
415                    if let Ok(checkout_output) = checkout_result {
416                        if checkout_output.status.success() {
417                            println!(
418                                "✅ Successfully created local branch '{name}' from origin/{name}"
419                            );
420                            return Ok(true);
421                        }
422                    }
423                }
424            }
425        }
426
427        // 5. Only fail if it doesn't exist anywhere
428        Ok(false)
429    }
430
431    /// Get the commit hash for a specific branch without switching branches
432    pub fn get_branch_commit_hash(&self, branch_name: &str) -> Result<String> {
433        let branch = self
434            .repo
435            .find_branch(branch_name, git2::BranchType::Local)
436            .map_err(|e| {
437                CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
438            })?;
439
440        let commit = branch.get().peel_to_commit().map_err(|e| {
441            CascadeError::branch(format!(
442                "Could not get commit for branch '{branch_name}': {e}"
443            ))
444        })?;
445
446        Ok(commit.id().to_string())
447    }
448
449    /// List all local branches
450    pub fn list_branches(&self) -> Result<Vec<String>> {
451        let branches = self
452            .repo
453            .branches(Some(git2::BranchType::Local))
454            .map_err(CascadeError::Git)?;
455
456        let mut branch_names = Vec::new();
457        for branch in branches {
458            let (branch, _) = branch.map_err(CascadeError::Git)?;
459            if let Some(name) = branch.name().map_err(CascadeError::Git)? {
460                branch_names.push(name.to_string());
461            }
462        }
463
464        Ok(branch_names)
465    }
466
467    /// Create a commit with all staged changes
468    pub fn commit(&self, message: &str) -> Result<String> {
469        // Validate git user configuration before attempting commit operations
470        self.validate_git_user_config()?;
471
472        let signature = self.get_signature()?;
473        let tree_id = self.get_index_tree()?;
474        let tree = self.repo.find_tree(tree_id).map_err(CascadeError::Git)?;
475
476        // Get parent commits
477        let head = self.repo.head().map_err(CascadeError::Git)?;
478        let parent_commit = head.peel_to_commit().map_err(CascadeError::Git)?;
479
480        let commit_id = self
481            .repo
482            .commit(
483                Some("HEAD"),
484                &signature,
485                &signature,
486                message,
487                &tree,
488                &[&parent_commit],
489            )
490            .map_err(CascadeError::Git)?;
491
492        Output::success(format!("Created commit: {commit_id} - {message}"));
493        Ok(commit_id.to_string())
494    }
495
496    /// Commit any staged changes with a default message
497    pub fn commit_staged_changes(&self, default_message: &str) -> Result<Option<String>> {
498        // Check if there are staged changes
499        let staged_files = self.get_staged_files()?;
500        if staged_files.is_empty() {
501            tracing::debug!("No staged changes to commit");
502            return Ok(None);
503        }
504
505        tracing::info!("Committing {} staged files", staged_files.len());
506        let commit_hash = self.commit(default_message)?;
507        Ok(Some(commit_hash))
508    }
509
510    /// Stage all changes
511    pub fn stage_all(&self) -> Result<()> {
512        let mut index = self.repo.index().map_err(CascadeError::Git)?;
513
514        index
515            .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
516            .map_err(CascadeError::Git)?;
517
518        index.write().map_err(CascadeError::Git)?;
519
520        tracing::debug!("Staged all changes");
521        Ok(())
522    }
523
524    /// Stage only specific files (safer than stage_all during rebase)
525    pub fn stage_files(&self, file_paths: &[&str]) -> Result<()> {
526        if file_paths.is_empty() {
527            tracing::debug!("No files to stage");
528            return Ok(());
529        }
530
531        let mut index = self.repo.index().map_err(CascadeError::Git)?;
532
533        for file_path in file_paths {
534            index
535                .add_path(std::path::Path::new(file_path))
536                .map_err(CascadeError::Git)?;
537        }
538
539        index.write().map_err(CascadeError::Git)?;
540
541        tracing::debug!(
542            "Staged {} specific files: {:?}",
543            file_paths.len(),
544            file_paths
545        );
546        Ok(())
547    }
548
549    /// Stage only files that had conflicts (safer for rebase operations)
550    pub fn stage_conflict_resolved_files(&self) -> Result<()> {
551        let conflicted_files = self.get_conflicted_files()?;
552        if conflicted_files.is_empty() {
553            tracing::debug!("No conflicted files to stage");
554            return Ok(());
555        }
556
557        let file_paths: Vec<&str> = conflicted_files.iter().map(|s| s.as_str()).collect();
558        self.stage_files(&file_paths)?;
559
560        tracing::debug!("Staged {} conflict-resolved files", conflicted_files.len());
561        Ok(())
562    }
563
564    /// Get repository path
565    pub fn path(&self) -> &Path {
566        &self.path
567    }
568
569    /// Check if a commit exists
570    pub fn commit_exists(&self, commit_hash: &str) -> Result<bool> {
571        match Oid::from_str(commit_hash) {
572            Ok(oid) => match self.repo.find_commit(oid) {
573                Ok(_) => Ok(true),
574                Err(_) => Ok(false),
575            },
576            Err(_) => Ok(false),
577        }
578    }
579
580    /// Get the HEAD commit object
581    pub fn get_head_commit(&self) -> Result<git2::Commit<'_>> {
582        let head = self
583            .repo
584            .head()
585            .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
586        head.peel_to_commit()
587            .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))
588    }
589
590    /// Get a commit object by hash
591    pub fn get_commit(&self, commit_hash: &str) -> Result<git2::Commit<'_>> {
592        let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
593
594        self.repo.find_commit(oid).map_err(CascadeError::Git)
595    }
596
597    /// Get the commit hash at the head of a branch
598    pub fn get_branch_head(&self, branch_name: &str) -> Result<String> {
599        let branch = self
600            .repo
601            .find_branch(branch_name, git2::BranchType::Local)
602            .map_err(|e| {
603                CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
604            })?;
605
606        let commit = branch.get().peel_to_commit().map_err(|e| {
607            CascadeError::branch(format!(
608                "Could not get commit for branch '{branch_name}': {e}"
609            ))
610        })?;
611
612        Ok(commit.id().to_string())
613    }
614
615    /// Validate git user configuration is properly set
616    pub fn validate_git_user_config(&self) -> Result<()> {
617        if let Ok(config) = self.repo.config() {
618            let name_result = config.get_string("user.name");
619            let email_result = config.get_string("user.email");
620
621            if let (Ok(name), Ok(email)) = (name_result, email_result) {
622                if !name.trim().is_empty() && !email.trim().is_empty() {
623                    tracing::debug!("Git user config validated: {} <{}>", name, email);
624                    return Ok(());
625                }
626            }
627        }
628
629        // Check if this is a CI environment where validation can be skipped
630        let is_ci = std::env::var("CI").is_ok();
631
632        if is_ci {
633            tracing::debug!("CI environment - skipping git user config validation");
634            return Ok(());
635        }
636
637        Output::warning("Git user configuration missing or incomplete");
638        Output::info("This can cause cherry-pick and commit operations to fail");
639        Output::info("Please configure git user information:");
640        Output::bullet("git config user.name \"Your Name\"".to_string());
641        Output::bullet("git config user.email \"your.email@example.com\"".to_string());
642        Output::info("Or set globally with the --global flag");
643
644        // Don't fail - let operations continue with fallback signature
645        // This preserves backward compatibility while providing guidance
646        Ok(())
647    }
648
649    /// Get a signature for commits with comprehensive fallback and validation
650    fn get_signature(&self) -> Result<Signature<'_>> {
651        // Try to get signature from Git config first
652        if let Ok(config) = self.repo.config() {
653            // Try global/system config first
654            let name_result = config.get_string("user.name");
655            let email_result = config.get_string("user.email");
656
657            if let (Ok(name), Ok(email)) = (name_result, email_result) {
658                if !name.trim().is_empty() && !email.trim().is_empty() {
659                    tracing::debug!("Using git config: {} <{}>", name, email);
660                    return Signature::now(&name, &email).map_err(CascadeError::Git);
661                }
662            } else {
663                tracing::debug!("Git user config incomplete or missing");
664            }
665        }
666
667        // Check if this is a CI environment where fallback is acceptable
668        let is_ci = std::env::var("CI").is_ok();
669
670        if is_ci {
671            tracing::debug!("CI environment detected, using fallback signature");
672            return Signature::now("Cascade CLI", "cascade@example.com").map_err(CascadeError::Git);
673        }
674
675        // Interactive environment - provide helpful guidance
676        tracing::warn!("Git user configuration missing - this can cause commit operations to fail");
677
678        // Try fallback signature, but warn about the issue
679        match Signature::now("Cascade CLI", "cascade@example.com") {
680            Ok(sig) => {
681                Output::warning("Git user not configured - using fallback signature");
682                Output::info("For better git history, run:");
683                Output::bullet("git config user.name \"Your Name\"".to_string());
684                Output::bullet("git config user.email \"your.email@example.com\"".to_string());
685                Output::info("Or set it globally with --global flag");
686                Ok(sig)
687            }
688            Err(e) => {
689                Err(CascadeError::branch(format!(
690                    "Cannot create git signature: {e}. Please configure git user with:\n  git config user.name \"Your Name\"\n  git config user.email \"your.email@example.com\""
691                )))
692            }
693        }
694    }
695
696    /// Configure remote callbacks with SSL settings
697    /// Priority: Cascade SSL config > Git config > Default
698    fn configure_remote_callbacks(&self) -> Result<git2::RemoteCallbacks<'_>> {
699        self.configure_remote_callbacks_with_fallback(false)
700    }
701
702    /// Determine if we should retry with DefaultCredentials based on git2 error classification
703    fn should_retry_with_default_credentials(&self, error: &git2::Error) -> bool {
704        match error.class() {
705            // Authentication errors that might be resolved with DefaultCredentials
706            git2::ErrorClass::Http => {
707                // HTTP errors often indicate authentication issues in corporate environments
708                match error.code() {
709                    git2::ErrorCode::Auth => true,
710                    _ => {
711                        // Check for specific HTTP authentication replay errors
712                        let error_string = error.to_string();
713                        error_string.contains("too many redirects")
714                            || error_string.contains("authentication replays")
715                            || error_string.contains("authentication required")
716                    }
717                }
718            }
719            git2::ErrorClass::Net => {
720                // Network errors that might be authentication-related
721                let error_string = error.to_string();
722                error_string.contains("authentication")
723                    || error_string.contains("unauthorized")
724                    || error_string.contains("forbidden")
725            }
726            _ => false,
727        }
728    }
729
730    /// Determine if we should fallback to git CLI based on git2 error classification
731    fn should_fallback_to_git_cli(&self, error: &git2::Error) -> bool {
732        match error.class() {
733            // SSL/TLS errors that git CLI handles better
734            git2::ErrorClass::Ssl => true,
735
736            // Certificate errors
737            git2::ErrorClass::Http if error.code() == git2::ErrorCode::Certificate => true,
738
739            // SSH errors that might need git CLI
740            git2::ErrorClass::Ssh => {
741                let error_string = error.to_string();
742                error_string.contains("no callback set")
743                    || error_string.contains("authentication required")
744            }
745
746            // Network errors that might be proxy/firewall related
747            git2::ErrorClass::Net => {
748                let error_string = error.to_string();
749                error_string.contains("TLS stream")
750                    || error_string.contains("SSL")
751                    || error_string.contains("proxy")
752                    || error_string.contains("firewall")
753            }
754
755            // General HTTP errors not handled by DefaultCredentials retry
756            git2::ErrorClass::Http => {
757                let error_string = error.to_string();
758                error_string.contains("TLS stream")
759                    || error_string.contains("SSL")
760                    || error_string.contains("proxy")
761            }
762
763            _ => false,
764        }
765    }
766
767    fn configure_remote_callbacks_with_fallback(
768        &self,
769        use_default_first: bool,
770    ) -> Result<git2::RemoteCallbacks<'_>> {
771        let mut callbacks = git2::RemoteCallbacks::new();
772
773        // Configure authentication with comprehensive credential support
774        let bitbucket_credentials = self.bitbucket_credentials.clone();
775        callbacks.credentials(move |url, username_from_url, allowed_types| {
776            tracing::debug!(
777                "Authentication requested for URL: {}, username: {:?}, allowed_types: {:?}",
778                url,
779                username_from_url,
780                allowed_types
781            );
782
783            // For SSH URLs with username
784            if allowed_types.contains(git2::CredentialType::SSH_KEY) {
785                if let Some(username) = username_from_url {
786                    tracing::debug!("Trying SSH key authentication for user: {}", username);
787                    return git2::Cred::ssh_key_from_agent(username);
788                }
789            }
790
791            // For HTTPS URLs, try multiple authentication methods in sequence
792            if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
793                // If we're in corporate network fallback mode, try DefaultCredentials first
794                if use_default_first {
795                    tracing::debug!("Corporate network mode: trying DefaultCredentials first");
796                    return git2::Cred::default();
797                }
798
799                if url.contains("bitbucket") {
800                    if let Some(creds) = &bitbucket_credentials {
801                        // Method 1: Username + Token (common for Bitbucket)
802                        if let (Some(username), Some(token)) = (&creds.username, &creds.token) {
803                            tracing::debug!("Trying Bitbucket username + token authentication");
804                            return git2::Cred::userpass_plaintext(username, token);
805                        }
806
807                        // Method 2: Token as username, empty password (alternate Bitbucket format)
808                        if let Some(token) = &creds.token {
809                            tracing::debug!("Trying Bitbucket token-as-username authentication");
810                            return git2::Cred::userpass_plaintext(token, "");
811                        }
812
813                        // Method 3: Just username (will prompt for password or use credential helper)
814                        if let Some(username) = &creds.username {
815                            tracing::debug!("Trying Bitbucket username authentication (will use credential helper)");
816                            return git2::Cred::username(username);
817                        }
818                    }
819                }
820
821                // Method 4: Default credential helper for all HTTPS URLs
822                tracing::debug!("Trying default credential helper for HTTPS authentication");
823                return git2::Cred::default();
824            }
825
826            // Fallback to default for any other cases
827            tracing::debug!("Using default credential fallback");
828            git2::Cred::default()
829        });
830
831        // Configure SSL certificate checking with system certificates by default
832        // This matches what tools like Graphite, Sapling, and Phabricator do
833        // Priority: 1. Use system certificates (default), 2. Manual overrides only if needed
834
835        let mut ssl_configured = false;
836
837        // Check for manual SSL overrides first (only when user explicitly needs them)
838        if let Some(ssl_config) = &self.ssl_config {
839            if ssl_config.accept_invalid_certs {
840                Output::warning(
841                    "SSL certificate verification DISABLED via Cascade config - this is insecure!",
842                );
843                callbacks.certificate_check(|_cert, _host| {
844                    tracing::debug!("⚠️  Accepting invalid certificate for host: {}", _host);
845                    Ok(git2::CertificateCheckStatus::CertificateOk)
846                });
847                ssl_configured = true;
848            } else if let Some(ca_path) = &ssl_config.ca_bundle_path {
849                Output::info(format!(
850                    "Using custom CA bundle from Cascade config: {ca_path}"
851                ));
852                callbacks.certificate_check(|_cert, host| {
853                    tracing::debug!("Using custom CA bundle for host: {}", host);
854                    Ok(git2::CertificateCheckStatus::CertificateOk)
855                });
856                ssl_configured = true;
857            }
858        }
859
860        // Check git config for manual overrides
861        if !ssl_configured {
862            if let Ok(config) = self.repo.config() {
863                let ssl_verify = config.get_bool("http.sslVerify").unwrap_or(true);
864
865                if !ssl_verify {
866                    Output::warning(
867                        "SSL certificate verification DISABLED via git config - this is insecure!",
868                    );
869                    callbacks.certificate_check(|_cert, host| {
870                        tracing::debug!("⚠️  Bypassing SSL verification for host: {}", host);
871                        Ok(git2::CertificateCheckStatus::CertificateOk)
872                    });
873                    ssl_configured = true;
874                } else if let Ok(ca_path) = config.get_string("http.sslCAInfo") {
875                    Output::info(format!("Using custom CA bundle from git config: {ca_path}"));
876                    callbacks.certificate_check(|_cert, host| {
877                        tracing::debug!("Using git config CA bundle for host: {}", host);
878                        Ok(git2::CertificateCheckStatus::CertificateOk)
879                    });
880                    ssl_configured = true;
881                }
882            }
883        }
884
885        // DEFAULT BEHAVIOR: Use system certificates (like git CLI and other modern tools)
886        // This should work out-of-the-box in corporate environments
887        if !ssl_configured {
888            tracing::debug!(
889                "Using system certificate store for SSL verification (default behavior)"
890            );
891
892            // For macOS with SecureTransport backend, try default certificate validation first
893            if cfg!(target_os = "macos") {
894                tracing::debug!("macOS detected - using default certificate validation");
895                // Don't set any certificate callback - let git2 use its default behavior
896                // This often works better with SecureTransport backend on macOS
897            } else {
898                // Use CertificatePassthrough for other platforms
899                callbacks.certificate_check(|_cert, host| {
900                    tracing::debug!("System certificate validation for host: {}", host);
901                    Ok(git2::CertificateCheckStatus::CertificatePassthrough)
902                });
903            }
904        }
905
906        Ok(callbacks)
907    }
908
909    /// Get the tree ID from the current index
910    fn get_index_tree(&self) -> Result<Oid> {
911        let mut index = self.repo.index().map_err(CascadeError::Git)?;
912
913        index.write_tree().map_err(CascadeError::Git)
914    }
915
916    /// Get repository status
917    pub fn get_status(&self) -> Result<git2::Statuses<'_>> {
918        self.repo.statuses(None).map_err(CascadeError::Git)
919    }
920
921    /// Get remote URL for a given remote name
922    pub fn get_remote_url(&self, name: &str) -> Result<String> {
923        let remote = self.repo.find_remote(name).map_err(CascadeError::Git)?;
924        Ok(remote.url().unwrap_or("unknown").to_string())
925    }
926
927    /// Cherry-pick a specific commit to the current branch
928    pub fn cherry_pick(&self, commit_hash: &str) -> Result<String> {
929        tracing::debug!("Cherry-picking commit {}", commit_hash);
930
931        // Validate git user configuration before attempting commit operations
932        self.validate_git_user_config()?;
933
934        let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
935        let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
936
937        // Get the commit's tree
938        let commit_tree = commit.tree().map_err(CascadeError::Git)?;
939
940        // Get parent tree for merge base
941        let parent_commit = if commit.parent_count() > 0 {
942            commit.parent(0).map_err(CascadeError::Git)?
943        } else {
944            // Root commit - use empty tree
945            let empty_tree_oid = self.repo.treebuilder(None)?.write()?;
946            let empty_tree = self.repo.find_tree(empty_tree_oid)?;
947            let sig = self.get_signature()?;
948            return self
949                .repo
950                .commit(
951                    Some("HEAD"),
952                    &sig,
953                    &sig,
954                    commit.message().unwrap_or("Cherry-picked commit"),
955                    &empty_tree,
956                    &[],
957                )
958                .map(|oid| oid.to_string())
959                .map_err(CascadeError::Git);
960        };
961
962        let parent_tree = parent_commit.tree().map_err(CascadeError::Git)?;
963
964        // Get current HEAD tree for 3-way merge
965        let head_commit = self.get_head_commit()?;
966        let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
967
968        // Perform 3-way merge
969        let mut index = self
970            .repo
971            .merge_trees(&parent_tree, &head_tree, &commit_tree, None)
972            .map_err(CascadeError::Git)?;
973
974        // Check for conflicts
975        if index.has_conflicts() {
976            return Err(CascadeError::branch(format!(
977                "Cherry-pick of {commit_hash} has conflicts that need manual resolution"
978            )));
979        }
980
981        // Write merged tree
982        let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
983        let merged_tree = self
984            .repo
985            .find_tree(merged_tree_oid)
986            .map_err(CascadeError::Git)?;
987
988        // Create new commit
989        let signature = self.get_signature()?;
990        let message = format!("Cherry-pick: {}", commit.message().unwrap_or(""));
991
992        let new_commit_oid = self
993            .repo
994            .commit(
995                Some("HEAD"),
996                &signature,
997                &signature,
998                &message,
999                &merged_tree,
1000                &[&head_commit],
1001            )
1002            .map_err(CascadeError::Git)?;
1003
1004        tracing::info!("Cherry-picked {} -> {}", commit_hash, new_commit_oid);
1005        Ok(new_commit_oid.to_string())
1006    }
1007
1008    /// Check for merge conflicts in the index
1009    pub fn has_conflicts(&self) -> Result<bool> {
1010        let index = self.repo.index().map_err(CascadeError::Git)?;
1011        Ok(index.has_conflicts())
1012    }
1013
1014    /// Get list of conflicted files
1015    pub fn get_conflicted_files(&self) -> Result<Vec<String>> {
1016        let index = self.repo.index().map_err(CascadeError::Git)?;
1017
1018        let mut conflicts = Vec::new();
1019
1020        // Iterate through index conflicts
1021        let conflict_iter = index.conflicts().map_err(CascadeError::Git)?;
1022
1023        for conflict in conflict_iter {
1024            let conflict = conflict.map_err(CascadeError::Git)?;
1025            if let Some(our) = conflict.our {
1026                if let Ok(path) = std::str::from_utf8(&our.path) {
1027                    conflicts.push(path.to_string());
1028                }
1029            } else if let Some(their) = conflict.their {
1030                if let Ok(path) = std::str::from_utf8(&their.path) {
1031                    conflicts.push(path.to_string());
1032                }
1033            }
1034        }
1035
1036        Ok(conflicts)
1037    }
1038
1039    /// Fetch from remote origin
1040    pub fn fetch(&self) -> Result<()> {
1041        tracing::info!("Fetching from origin");
1042
1043        let mut remote = self
1044            .repo
1045            .find_remote("origin")
1046            .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
1047
1048        // Configure callbacks with SSL settings from git config
1049        let callbacks = self.configure_remote_callbacks()?;
1050
1051        // Fetch options with authentication and SSL config
1052        let mut fetch_options = git2::FetchOptions::new();
1053        fetch_options.remote_callbacks(callbacks);
1054
1055        // Fetch with authentication
1056        match remote.fetch::<&str>(&[], Some(&mut fetch_options), None) {
1057            Ok(_) => {
1058                tracing::debug!("Fetch completed successfully");
1059                Ok(())
1060            }
1061            Err(e) => {
1062                if self.should_retry_with_default_credentials(&e) {
1063                    tracing::debug!(
1064                        "Authentication error detected (class: {:?}, code: {:?}): {}, retrying with DefaultCredentials",
1065                        e.class(), e.code(), e
1066                    );
1067
1068                    // Retry with DefaultCredentials for corporate networks
1069                    let callbacks = self.configure_remote_callbacks_with_fallback(true)?;
1070                    let mut fetch_options = git2::FetchOptions::new();
1071                    fetch_options.remote_callbacks(callbacks);
1072
1073                    match remote.fetch::<&str>(&[], Some(&mut fetch_options), None) {
1074                        Ok(_) => {
1075                            tracing::debug!("Fetch succeeded with DefaultCredentials");
1076                            return Ok(());
1077                        }
1078                        Err(retry_error) => {
1079                            tracing::debug!(
1080                                "DefaultCredentials retry failed: {}, falling back to git CLI",
1081                                retry_error
1082                            );
1083                            return self.fetch_with_git_cli();
1084                        }
1085                    }
1086                }
1087
1088                if self.should_fallback_to_git_cli(&e) {
1089                    tracing::debug!(
1090                        "Network/SSL error detected (class: {:?}, code: {:?}): {}, falling back to git CLI for fetch operation",
1091                        e.class(), e.code(), e
1092                    );
1093                    return self.fetch_with_git_cli();
1094                }
1095                Err(CascadeError::Git(e))
1096            }
1097        }
1098    }
1099
1100    /// Pull changes from remote (fetch + merge)
1101    pub fn pull(&self, branch: &str) -> Result<()> {
1102        tracing::info!("Pulling branch: {}", branch);
1103
1104        // First fetch - this now includes TLS fallback
1105        match self.fetch() {
1106            Ok(_) => {}
1107            Err(e) => {
1108                // If fetch failed even with CLI fallback, try full git pull as last resort
1109                let error_string = e.to_string();
1110                if error_string.contains("TLS stream") || error_string.contains("SSL") {
1111                    tracing::warn!(
1112                        "git2 error detected: {}, falling back to git CLI for pull operation",
1113                        e
1114                    );
1115                    return self.pull_with_git_cli(branch);
1116                }
1117                return Err(e);
1118            }
1119        }
1120
1121        // Get remote tracking branch
1122        let remote_branch_name = format!("origin/{branch}");
1123        let remote_oid = self
1124            .repo
1125            .refname_to_id(&format!("refs/remotes/{remote_branch_name}"))
1126            .map_err(|e| {
1127                CascadeError::branch(format!("Remote branch {remote_branch_name} not found: {e}"))
1128            })?;
1129
1130        let remote_commit = self
1131            .repo
1132            .find_commit(remote_oid)
1133            .map_err(CascadeError::Git)?;
1134
1135        // Get current HEAD
1136        let head_commit = self.get_head_commit()?;
1137
1138        // Check if we need to merge
1139        if head_commit.id() == remote_commit.id() {
1140            tracing::debug!("Already up to date");
1141            return Ok(());
1142        }
1143
1144        // Perform merge
1145        let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
1146        let remote_tree = remote_commit.tree().map_err(CascadeError::Git)?;
1147
1148        // Find merge base
1149        let merge_base_oid = self
1150            .repo
1151            .merge_base(head_commit.id(), remote_commit.id())
1152            .map_err(CascadeError::Git)?;
1153        let merge_base_commit = self
1154            .repo
1155            .find_commit(merge_base_oid)
1156            .map_err(CascadeError::Git)?;
1157        let merge_base_tree = merge_base_commit.tree().map_err(CascadeError::Git)?;
1158
1159        // 3-way merge
1160        let mut index = self
1161            .repo
1162            .merge_trees(&merge_base_tree, &head_tree, &remote_tree, None)
1163            .map_err(CascadeError::Git)?;
1164
1165        if index.has_conflicts() {
1166            return Err(CascadeError::branch(
1167                "Pull has conflicts that need manual resolution".to_string(),
1168            ));
1169        }
1170
1171        // Write merged tree and create merge commit
1172        let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
1173        let merged_tree = self
1174            .repo
1175            .find_tree(merged_tree_oid)
1176            .map_err(CascadeError::Git)?;
1177
1178        let signature = self.get_signature()?;
1179        let message = format!("Merge branch '{branch}' from origin");
1180
1181        self.repo
1182            .commit(
1183                Some("HEAD"),
1184                &signature,
1185                &signature,
1186                &message,
1187                &merged_tree,
1188                &[&head_commit, &remote_commit],
1189            )
1190            .map_err(CascadeError::Git)?;
1191
1192        tracing::info!("Pull completed successfully");
1193        Ok(())
1194    }
1195
1196    /// Push current branch to remote
1197    pub fn push(&self, branch: &str) -> Result<()> {
1198        // Pushing branch to remote
1199
1200        let mut remote = self
1201            .repo
1202            .find_remote("origin")
1203            .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
1204
1205        let remote_url = remote.url().unwrap_or("unknown").to_string();
1206        tracing::debug!("Remote URL: {}", remote_url);
1207
1208        let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
1209        tracing::debug!("Push refspec: {}", refspec);
1210
1211        // Configure callbacks with enhanced SSL settings and error handling
1212        let mut callbacks = self.configure_remote_callbacks()?;
1213
1214        // Add enhanced progress and error callbacks for better debugging
1215        callbacks.push_update_reference(|refname, status| {
1216            if let Some(msg) = status {
1217                tracing::error!("Push failed for ref {}: {}", refname, msg);
1218                return Err(git2::Error::from_str(&format!("Push failed: {msg}")));
1219            }
1220            tracing::debug!("Push succeeded for ref: {}", refname);
1221            Ok(())
1222        });
1223
1224        // Push options with authentication and SSL config
1225        let mut push_options = git2::PushOptions::new();
1226        push_options.remote_callbacks(callbacks);
1227
1228        // Attempt push with enhanced error reporting
1229        match remote.push(&[&refspec], Some(&mut push_options)) {
1230            Ok(_) => {
1231                tracing::info!("Push completed successfully for branch: {}", branch);
1232                Ok(())
1233            }
1234            Err(e) => {
1235                tracing::debug!(
1236                    "git2 push error: {} (class: {:?}, code: {:?})",
1237                    e,
1238                    e.class(),
1239                    e.code()
1240                );
1241
1242                if self.should_retry_with_default_credentials(&e) {
1243                    tracing::debug!(
1244                        "Authentication error detected (class: {:?}, code: {:?}): {}, retrying with DefaultCredentials",
1245                        e.class(), e.code(), e
1246                    );
1247
1248                    // Retry with DefaultCredentials for corporate networks
1249                    let callbacks = self.configure_remote_callbacks_with_fallback(true)?;
1250                    let mut push_options = git2::PushOptions::new();
1251                    push_options.remote_callbacks(callbacks);
1252
1253                    match remote.push(&[&refspec], Some(&mut push_options)) {
1254                        Ok(_) => {
1255                            tracing::debug!("Push succeeded with DefaultCredentials");
1256                            return Ok(());
1257                        }
1258                        Err(retry_error) => {
1259                            tracing::debug!(
1260                                "DefaultCredentials retry failed: {}, falling back to git CLI",
1261                                retry_error
1262                            );
1263                            return self.push_with_git_cli(branch);
1264                        }
1265                    }
1266                }
1267
1268                if self.should_fallback_to_git_cli(&e) {
1269                    tracing::debug!(
1270                        "Network/SSL error detected (class: {:?}, code: {:?}): {}, falling back to git CLI for push operation",
1271                        e.class(), e.code(), e
1272                    );
1273                    return self.push_with_git_cli(branch);
1274                }
1275
1276                // Create concise error message
1277                let error_msg = if e.to_string().contains("authentication") {
1278                    format!(
1279                        "Authentication failed for branch '{branch}'. Try: git push origin {branch}"
1280                    )
1281                } else {
1282                    format!("Failed to push branch '{branch}': {e}")
1283                };
1284
1285                tracing::error!("{}", error_msg);
1286                Err(CascadeError::branch(error_msg))
1287            }
1288        }
1289    }
1290
1291    /// Fallback push method using git CLI instead of git2
1292    /// This is used when git2 has TLS/SSL or auth issues but git CLI works fine
1293    fn push_with_git_cli(&self, branch: &str) -> Result<()> {
1294        let output = std::process::Command::new("git")
1295            .args(["push", "origin", branch])
1296            .current_dir(&self.path)
1297            .output()
1298            .map_err(|e| CascadeError::branch(format!("Failed to execute git command: {e}")))?;
1299
1300        if output.status.success() {
1301            // Silent success - no need to log when fallback works
1302            Ok(())
1303        } else {
1304            let stderr = String::from_utf8_lossy(&output.stderr);
1305            let _stdout = String::from_utf8_lossy(&output.stdout);
1306            // Extract the most relevant error message
1307            let error_msg = if stderr.contains("SSL_connect") || stderr.contains("SSL_ERROR") {
1308                "Network error: Unable to connect to repository (VPN may be required)".to_string()
1309            } else if stderr.contains("repository") && stderr.contains("not found") {
1310                "Repository not found - check your Bitbucket configuration".to_string()
1311            } else if stderr.contains("authentication") || stderr.contains("403") {
1312                "Authentication failed - check your credentials".to_string()
1313            } else {
1314                // For other errors, just show the stderr without the verbose prefix
1315                stderr.trim().to_string()
1316            };
1317            tracing::error!("{}", error_msg);
1318            Err(CascadeError::branch(error_msg))
1319        }
1320    }
1321
1322    /// Fallback fetch method using git CLI instead of git2
1323    /// This is used when git2 has TLS/SSL issues but git CLI works fine
1324    fn fetch_with_git_cli(&self) -> Result<()> {
1325        tracing::info!("Using git CLI fallback for fetch operation");
1326
1327        let output = std::process::Command::new("git")
1328            .args(["fetch", "origin"])
1329            .current_dir(&self.path)
1330            .output()
1331            .map_err(|e| {
1332                CascadeError::Git(git2::Error::from_str(&format!(
1333                    "Failed to execute git command: {e}"
1334                )))
1335            })?;
1336
1337        if output.status.success() {
1338            tracing::info!("✅ Git CLI fetch succeeded");
1339            Ok(())
1340        } else {
1341            let stderr = String::from_utf8_lossy(&output.stderr);
1342            let stdout = String::from_utf8_lossy(&output.stdout);
1343            let error_msg = format!(
1344                "Git CLI fetch failed: {}\nStdout: {}\nStderr: {}",
1345                output.status, stdout, stderr
1346            );
1347            tracing::error!("{}", error_msg);
1348            Err(CascadeError::Git(git2::Error::from_str(&error_msg)))
1349        }
1350    }
1351
1352    /// Fallback pull method using git CLI instead of git2
1353    /// This is used when git2 has TLS/SSL issues but git CLI works fine
1354    fn pull_with_git_cli(&self, branch: &str) -> Result<()> {
1355        tracing::info!("Using git CLI fallback for pull operation: {}", branch);
1356
1357        let output = std::process::Command::new("git")
1358            .args(["pull", "origin", branch])
1359            .current_dir(&self.path)
1360            .output()
1361            .map_err(|e| {
1362                CascadeError::Git(git2::Error::from_str(&format!(
1363                    "Failed to execute git command: {e}"
1364                )))
1365            })?;
1366
1367        if output.status.success() {
1368            tracing::info!("✅ Git CLI pull succeeded for branch: {}", branch);
1369            Ok(())
1370        } else {
1371            let stderr = String::from_utf8_lossy(&output.stderr);
1372            let stdout = String::from_utf8_lossy(&output.stdout);
1373            let error_msg = format!(
1374                "Git CLI pull failed for branch '{}': {}\nStdout: {}\nStderr: {}",
1375                branch, output.status, stdout, stderr
1376            );
1377            tracing::error!("{}", error_msg);
1378            Err(CascadeError::Git(git2::Error::from_str(&error_msg)))
1379        }
1380    }
1381
1382    /// Fallback force push method using git CLI instead of git2
1383    /// This is used when git2 has TLS/SSL issues but git CLI works fine
1384    fn force_push_with_git_cli(&self, branch: &str) -> Result<()> {
1385        tracing::info!(
1386            "Using git CLI fallback for force push operation: {}",
1387            branch
1388        );
1389
1390        let output = std::process::Command::new("git")
1391            .args(["push", "--force", "origin", branch])
1392            .current_dir(&self.path)
1393            .output()
1394            .map_err(|e| CascadeError::branch(format!("Failed to execute git command: {e}")))?;
1395
1396        if output.status.success() {
1397            tracing::info!("✅ Git CLI force push succeeded for branch: {}", branch);
1398            Ok(())
1399        } else {
1400            let stderr = String::from_utf8_lossy(&output.stderr);
1401            let stdout = String::from_utf8_lossy(&output.stdout);
1402            let error_msg = format!(
1403                "Git CLI force push failed for branch '{}': {}\nStdout: {}\nStderr: {}",
1404                branch, output.status, stdout, stderr
1405            );
1406            tracing::error!("{}", error_msg);
1407            Err(CascadeError::branch(error_msg))
1408        }
1409    }
1410
1411    /// Delete a local branch
1412    pub fn delete_branch(&self, name: &str) -> Result<()> {
1413        self.delete_branch_with_options(name, false)
1414    }
1415
1416    /// Delete a local branch with force option to bypass safety checks
1417    pub fn delete_branch_unsafe(&self, name: &str) -> Result<()> {
1418        self.delete_branch_with_options(name, true)
1419    }
1420
1421    /// Internal branch deletion implementation with safety options
1422    fn delete_branch_with_options(&self, name: &str, force_unsafe: bool) -> Result<()> {
1423        info!("Attempting to delete branch: {}", name);
1424
1425        // Enhanced safety check: Detect unpushed commits before deletion
1426        if !force_unsafe {
1427            let safety_result = self.check_branch_deletion_safety(name)?;
1428            if let Some(safety_info) = safety_result {
1429                // Branch has unpushed commits, get user confirmation
1430                self.handle_branch_deletion_confirmation(name, &safety_info)?;
1431            }
1432        }
1433
1434        let mut branch = self
1435            .repo
1436            .find_branch(name, git2::BranchType::Local)
1437            .map_err(|e| CascadeError::branch(format!("Could not find branch '{name}': {e}")))?;
1438
1439        branch
1440            .delete()
1441            .map_err(|e| CascadeError::branch(format!("Could not delete branch '{name}': {e}")))?;
1442
1443        info!("Successfully deleted branch '{}'", name);
1444        Ok(())
1445    }
1446
1447    /// Get commits between two references
1448    pub fn get_commits_between(&self, from: &str, to: &str) -> Result<Vec<git2::Commit<'_>>> {
1449        let from_oid = self
1450            .repo
1451            .refname_to_id(&format!("refs/heads/{from}"))
1452            .or_else(|_| Oid::from_str(from))
1453            .map_err(|e| CascadeError::branch(format!("Invalid from reference '{from}': {e}")))?;
1454
1455        let to_oid = self
1456            .repo
1457            .refname_to_id(&format!("refs/heads/{to}"))
1458            .or_else(|_| Oid::from_str(to))
1459            .map_err(|e| CascadeError::branch(format!("Invalid to reference '{to}': {e}")))?;
1460
1461        let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
1462
1463        revwalk.push(to_oid).map_err(CascadeError::Git)?;
1464        revwalk.hide(from_oid).map_err(CascadeError::Git)?;
1465
1466        let mut commits = Vec::new();
1467        for oid in revwalk {
1468            let oid = oid.map_err(CascadeError::Git)?;
1469            let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
1470            commits.push(commit);
1471        }
1472
1473        Ok(commits)
1474    }
1475
1476    /// Force push one branch's content to another branch name
1477    /// This is used to preserve PR history while updating branch contents after rebase
1478    pub fn force_push_branch(&self, target_branch: &str, source_branch: &str) -> Result<()> {
1479        self.force_push_branch_with_options(target_branch, source_branch, false)
1480    }
1481
1482    /// Force push with explicit force flag to bypass safety checks
1483    pub fn force_push_branch_unsafe(&self, target_branch: &str, source_branch: &str) -> Result<()> {
1484        self.force_push_branch_with_options(target_branch, source_branch, true)
1485    }
1486
1487    /// Internal force push implementation with safety options
1488    fn force_push_branch_with_options(
1489        &self,
1490        target_branch: &str,
1491        source_branch: &str,
1492        force_unsafe: bool,
1493    ) -> Result<()> {
1494        info!(
1495            "Force pushing {} content to {} to preserve PR history",
1496            source_branch, target_branch
1497        );
1498
1499        // Enhanced safety check: Detect potential data loss and get user confirmation
1500        if !force_unsafe {
1501            let safety_result = self.check_force_push_safety_enhanced(target_branch)?;
1502            if let Some(backup_info) = safety_result {
1503                // Create backup branch before force push
1504                self.create_backup_branch(target_branch, &backup_info.remote_commit_id)?;
1505                info!(
1506                    "✅ Created backup branch: {}",
1507                    backup_info.backup_branch_name
1508                );
1509            }
1510        }
1511
1512        // First, ensure we have the latest changes for the source branch
1513        let source_ref = self
1514            .repo
1515            .find_reference(&format!("refs/heads/{source_branch}"))
1516            .map_err(|e| {
1517                CascadeError::config(format!("Failed to find source branch {source_branch}: {e}"))
1518            })?;
1519        let _source_commit = source_ref.peel_to_commit().map_err(|e| {
1520            CascadeError::config(format!(
1521                "Failed to get commit for source branch {source_branch}: {e}"
1522            ))
1523        })?;
1524
1525        // Force push to remote without modifying local target branch
1526        let mut remote = self
1527            .repo
1528            .find_remote("origin")
1529            .map_err(|e| CascadeError::config(format!("Failed to find origin remote: {e}")))?;
1530
1531        // Push source branch content to remote target branch
1532        let refspec = format!("+refs/heads/{source_branch}:refs/heads/{target_branch}");
1533
1534        // Configure callbacks with SSL settings from git config
1535        let callbacks = self.configure_remote_callbacks()?;
1536
1537        // Push options for force push with SSL config
1538        let mut push_options = git2::PushOptions::new();
1539        push_options.remote_callbacks(callbacks);
1540
1541        match remote.push(&[&refspec], Some(&mut push_options)) {
1542            Ok(_) => {}
1543            Err(e) => {
1544                if self.should_retry_with_default_credentials(&e) {
1545                    tracing::debug!(
1546                        "Authentication error detected (class: {:?}, code: {:?}): {}, retrying with DefaultCredentials",
1547                        e.class(), e.code(), e
1548                    );
1549
1550                    // Retry with DefaultCredentials for corporate networks
1551                    let callbacks = self.configure_remote_callbacks_with_fallback(true)?;
1552                    let mut push_options = git2::PushOptions::new();
1553                    push_options.remote_callbacks(callbacks);
1554
1555                    match remote.push(&[&refspec], Some(&mut push_options)) {
1556                        Ok(_) => {
1557                            tracing::debug!("Force push succeeded with DefaultCredentials");
1558                            // Success - continue to normal success path
1559                        }
1560                        Err(retry_error) => {
1561                            tracing::debug!(
1562                                "DefaultCredentials retry failed: {}, falling back to git CLI",
1563                                retry_error
1564                            );
1565                            return self.force_push_with_git_cli(target_branch);
1566                        }
1567                    }
1568                } else if self.should_fallback_to_git_cli(&e) {
1569                    tracing::debug!(
1570                        "Network/SSL error detected (class: {:?}, code: {:?}): {}, falling back to git CLI for force push operation",
1571                        e.class(), e.code(), e
1572                    );
1573                    return self.force_push_with_git_cli(target_branch);
1574                } else {
1575                    return Err(CascadeError::config(format!(
1576                        "Failed to force push {target_branch}: {e}"
1577                    )));
1578                }
1579            }
1580        }
1581
1582        info!(
1583            "✅ Successfully force pushed {} to preserve PR history",
1584            target_branch
1585        );
1586        Ok(())
1587    }
1588
1589    /// Enhanced safety check for force push operations with user confirmation
1590    /// Returns backup info if data would be lost and user confirms
1591    fn check_force_push_safety_enhanced(
1592        &self,
1593        target_branch: &str,
1594    ) -> Result<Option<ForceBackupInfo>> {
1595        // First fetch latest remote changes to ensure we have up-to-date information
1596        match self.fetch() {
1597            Ok(_) => {}
1598            Err(e) => {
1599                // If fetch fails, warn but don't block the operation
1600                warn!("Could not fetch latest changes for safety check: {}", e);
1601            }
1602        }
1603
1604        // Check if there are commits on the remote that would be lost
1605        let remote_ref = format!("refs/remotes/origin/{target_branch}");
1606        let local_ref = format!("refs/heads/{target_branch}");
1607
1608        // Try to find both local and remote references
1609        let local_commit = match self.repo.find_reference(&local_ref) {
1610            Ok(reference) => reference.peel_to_commit().ok(),
1611            Err(_) => None,
1612        };
1613
1614        let remote_commit = match self.repo.find_reference(&remote_ref) {
1615            Ok(reference) => reference.peel_to_commit().ok(),
1616            Err(_) => None,
1617        };
1618
1619        // If we have both commits, check for divergence
1620        if let (Some(local), Some(remote)) = (local_commit, remote_commit) {
1621            if local.id() != remote.id() {
1622                // Check if the remote has commits that the local doesn't have
1623                let merge_base_oid = self
1624                    .repo
1625                    .merge_base(local.id(), remote.id())
1626                    .map_err(|e| CascadeError::config(format!("Failed to find merge base: {e}")))?;
1627
1628                // If merge base != remote commit, remote has commits that would be lost
1629                if merge_base_oid != remote.id() {
1630                    let commits_to_lose = self.count_commits_between(
1631                        &merge_base_oid.to_string(),
1632                        &remote.id().to_string(),
1633                    )?;
1634
1635                    // Create backup branch name with timestamp
1636                    let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1637                    let backup_branch_name = format!("{target_branch}_backup_{timestamp}");
1638
1639                    warn!(
1640                        "⚠️  Force push to '{}' would overwrite {} commits on remote",
1641                        target_branch, commits_to_lose
1642                    );
1643
1644                    // Check if we're in a non-interactive environment (CI/testing)
1645                    if std::env::var("CI").is_ok() || std::env::var("FORCE_PUSH_NO_CONFIRM").is_ok()
1646                    {
1647                        info!(
1648                            "Non-interactive environment detected, proceeding with backup creation"
1649                        );
1650                        return Ok(Some(ForceBackupInfo {
1651                            backup_branch_name,
1652                            remote_commit_id: remote.id().to_string(),
1653                            commits_that_would_be_lost: commits_to_lose,
1654                        }));
1655                    }
1656
1657                    // Interactive confirmation
1658                    println!("\n⚠️  FORCE PUSH WARNING ⚠️");
1659                    println!("Force push to '{target_branch}' would overwrite {commits_to_lose} commits on remote:");
1660
1661                    // Show the commits that would be lost
1662                    match self
1663                        .get_commits_between(&merge_base_oid.to_string(), &remote.id().to_string())
1664                    {
1665                        Ok(commits) => {
1666                            println!("\nCommits that would be lost:");
1667                            for (i, commit) in commits.iter().take(5).enumerate() {
1668                                let short_hash = &commit.id().to_string()[..8];
1669                                let summary = commit.summary().unwrap_or("<no message>");
1670                                println!("  {}. {} - {}", i + 1, short_hash, summary);
1671                            }
1672                            if commits.len() > 5 {
1673                                println!("  ... and {} more commits", commits.len() - 5);
1674                            }
1675                        }
1676                        Err(_) => {
1677                            println!("  (Unable to retrieve commit details)");
1678                        }
1679                    }
1680
1681                    println!("\nA backup branch '{backup_branch_name}' will be created before proceeding.");
1682
1683                    let confirmed = Confirm::with_theme(&ColorfulTheme::default())
1684                        .with_prompt("Do you want to proceed with the force push?")
1685                        .default(false)
1686                        .interact()
1687                        .map_err(|e| {
1688                            CascadeError::config(format!("Failed to get user confirmation: {e}"))
1689                        })?;
1690
1691                    if !confirmed {
1692                        return Err(CascadeError::config(
1693                            "Force push cancelled by user. Use --force to bypass this check."
1694                                .to_string(),
1695                        ));
1696                    }
1697
1698                    return Ok(Some(ForceBackupInfo {
1699                        backup_branch_name,
1700                        remote_commit_id: remote.id().to_string(),
1701                        commits_that_would_be_lost: commits_to_lose,
1702                    }));
1703                }
1704            }
1705        }
1706
1707        Ok(None)
1708    }
1709
1710    /// Create a backup branch pointing to the remote commit that would be lost
1711    fn create_backup_branch(&self, original_branch: &str, remote_commit_id: &str) -> Result<()> {
1712        let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1713        let backup_branch_name = format!("{original_branch}_backup_{timestamp}");
1714
1715        // Parse the commit ID
1716        let commit_oid = Oid::from_str(remote_commit_id).map_err(|e| {
1717            CascadeError::config(format!("Invalid commit ID {remote_commit_id}: {e}"))
1718        })?;
1719
1720        // Find the commit
1721        let commit = self.repo.find_commit(commit_oid).map_err(|e| {
1722            CascadeError::config(format!("Failed to find commit {remote_commit_id}: {e}"))
1723        })?;
1724
1725        // Create the backup branch
1726        self.repo
1727            .branch(&backup_branch_name, &commit, false)
1728            .map_err(|e| {
1729                CascadeError::config(format!(
1730                    "Failed to create backup branch {backup_branch_name}: {e}"
1731                ))
1732            })?;
1733
1734        info!(
1735            "✅ Created backup branch '{}' pointing to {}",
1736            backup_branch_name,
1737            &remote_commit_id[..8]
1738        );
1739        Ok(())
1740    }
1741
1742    /// Check if branch deletion is safe by detecting unpushed commits
1743    /// Returns safety info if there are concerns that need user attention
1744    fn check_branch_deletion_safety(
1745        &self,
1746        branch_name: &str,
1747    ) -> Result<Option<BranchDeletionSafety>> {
1748        // First, try to fetch latest remote changes
1749        match self.fetch() {
1750            Ok(_) => {}
1751            Err(e) => {
1752                warn!(
1753                    "Could not fetch latest changes for branch deletion safety check: {}",
1754                    e
1755                );
1756            }
1757        }
1758
1759        // Find the branch
1760        let branch = self
1761            .repo
1762            .find_branch(branch_name, git2::BranchType::Local)
1763            .map_err(|e| {
1764                CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
1765            })?;
1766
1767        let _branch_commit = branch.get().peel_to_commit().map_err(|e| {
1768            CascadeError::branch(format!(
1769                "Could not get commit for branch '{branch_name}': {e}"
1770            ))
1771        })?;
1772
1773        // Determine the main branch (try common names)
1774        let main_branch_name = self.detect_main_branch()?;
1775
1776        // Check if branch is merged to main
1777        let is_merged_to_main = self.is_branch_merged_to_main(branch_name, &main_branch_name)?;
1778
1779        // Find the upstream/remote tracking branch
1780        let remote_tracking_branch = self.get_remote_tracking_branch(branch_name);
1781
1782        let mut unpushed_commits = Vec::new();
1783
1784        // Check for unpushed commits compared to remote tracking branch
1785        if let Some(ref remote_branch) = remote_tracking_branch {
1786            match self.get_commits_between(remote_branch, branch_name) {
1787                Ok(commits) => {
1788                    unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1789                }
1790                Err(_) => {
1791                    // If we can't compare with remote, check against main branch
1792                    if !is_merged_to_main {
1793                        if let Ok(commits) =
1794                            self.get_commits_between(&main_branch_name, branch_name)
1795                        {
1796                            unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1797                        }
1798                    }
1799                }
1800            }
1801        } else if !is_merged_to_main {
1802            // No remote tracking branch, check against main
1803            if let Ok(commits) = self.get_commits_between(&main_branch_name, branch_name) {
1804                unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1805            }
1806        }
1807
1808        // If there are concerns, return safety info
1809        if !unpushed_commits.is_empty() || (!is_merged_to_main && remote_tracking_branch.is_none())
1810        {
1811            Ok(Some(BranchDeletionSafety {
1812                unpushed_commits,
1813                remote_tracking_branch,
1814                is_merged_to_main,
1815                main_branch_name,
1816            }))
1817        } else {
1818            Ok(None)
1819        }
1820    }
1821
1822    /// Handle user confirmation for branch deletion with safety concerns
1823    fn handle_branch_deletion_confirmation(
1824        &self,
1825        branch_name: &str,
1826        safety_info: &BranchDeletionSafety,
1827    ) -> Result<()> {
1828        // Check if we're in a non-interactive environment
1829        if std::env::var("CI").is_ok() || std::env::var("BRANCH_DELETE_NO_CONFIRM").is_ok() {
1830            return Err(CascadeError::branch(
1831                format!(
1832                    "Branch '{branch_name}' has {} unpushed commits and cannot be deleted in non-interactive mode. Use --force to override.",
1833                    safety_info.unpushed_commits.len()
1834                )
1835            ));
1836        }
1837
1838        // Interactive warning and confirmation
1839        println!("\n⚠️  BRANCH DELETION WARNING ⚠️");
1840        println!("Branch '{branch_name}' has potential issues:");
1841
1842        if !safety_info.unpushed_commits.is_empty() {
1843            println!(
1844                "\n🔍 Unpushed commits ({} total):",
1845                safety_info.unpushed_commits.len()
1846            );
1847
1848            // Show details of unpushed commits
1849            for (i, commit_id) in safety_info.unpushed_commits.iter().take(5).enumerate() {
1850                if let Ok(commit) = self.repo.find_commit(Oid::from_str(commit_id).unwrap()) {
1851                    let short_hash = &commit_id[..8];
1852                    let summary = commit.summary().unwrap_or("<no message>");
1853                    println!("  {}. {} - {}", i + 1, short_hash, summary);
1854                }
1855            }
1856
1857            if safety_info.unpushed_commits.len() > 5 {
1858                println!(
1859                    "  ... and {} more commits",
1860                    safety_info.unpushed_commits.len() - 5
1861                );
1862            }
1863        }
1864
1865        if !safety_info.is_merged_to_main {
1866            println!("\n📋 Branch status:");
1867            println!("  • Not merged to '{}'", safety_info.main_branch_name);
1868            if let Some(ref remote) = safety_info.remote_tracking_branch {
1869                println!("  • Remote tracking branch: {remote}");
1870            } else {
1871                println!("  • No remote tracking branch");
1872            }
1873        }
1874
1875        println!("\n💡 Safer alternatives:");
1876        if !safety_info.unpushed_commits.is_empty() {
1877            if let Some(ref _remote) = safety_info.remote_tracking_branch {
1878                println!("  • Push commits first: git push origin {branch_name}");
1879            } else {
1880                println!("  • Create and push to remote: git push -u origin {branch_name}");
1881            }
1882        }
1883        if !safety_info.is_merged_to_main {
1884            println!(
1885                "  • Merge to {} first: git checkout {} && git merge {branch_name}",
1886                safety_info.main_branch_name, safety_info.main_branch_name
1887            );
1888        }
1889
1890        let confirmed = Confirm::with_theme(&ColorfulTheme::default())
1891            .with_prompt("Do you want to proceed with deleting this branch?")
1892            .default(false)
1893            .interact()
1894            .map_err(|e| CascadeError::branch(format!("Failed to get user confirmation: {e}")))?;
1895
1896        if !confirmed {
1897            return Err(CascadeError::branch(
1898                "Branch deletion cancelled by user. Use --force to bypass this check.".to_string(),
1899            ));
1900        }
1901
1902        Ok(())
1903    }
1904
1905    /// Detect the main branch name (main, master, develop)
1906    pub fn detect_main_branch(&self) -> Result<String> {
1907        let main_candidates = ["main", "master", "develop", "trunk"];
1908
1909        for candidate in &main_candidates {
1910            if self
1911                .repo
1912                .find_branch(candidate, git2::BranchType::Local)
1913                .is_ok()
1914            {
1915                return Ok(candidate.to_string());
1916            }
1917        }
1918
1919        // Fallback to HEAD's target if it's a symbolic reference
1920        if let Ok(head) = self.repo.head() {
1921            if let Some(name) = head.shorthand() {
1922                return Ok(name.to_string());
1923            }
1924        }
1925
1926        // Final fallback
1927        Ok("main".to_string())
1928    }
1929
1930    /// Check if a branch is merged to the main branch
1931    fn is_branch_merged_to_main(&self, branch_name: &str, main_branch: &str) -> Result<bool> {
1932        // Get the commits between main and the branch
1933        match self.get_commits_between(main_branch, branch_name) {
1934            Ok(commits) => Ok(commits.is_empty()),
1935            Err(_) => {
1936                // If we can't determine, assume not merged for safety
1937                Ok(false)
1938            }
1939        }
1940    }
1941
1942    /// Get the remote tracking branch for a local branch
1943    fn get_remote_tracking_branch(&self, branch_name: &str) -> Option<String> {
1944        // Try common remote tracking branch patterns
1945        let remote_candidates = [
1946            format!("origin/{branch_name}"),
1947            format!("remotes/origin/{branch_name}"),
1948        ];
1949
1950        for candidate in &remote_candidates {
1951            if self
1952                .repo
1953                .find_reference(&format!(
1954                    "refs/remotes/{}",
1955                    candidate.replace("remotes/", "")
1956                ))
1957                .is_ok()
1958            {
1959                return Some(candidate.clone());
1960            }
1961        }
1962
1963        None
1964    }
1965
1966    /// Check if checkout operation is safe
1967    fn check_checkout_safety(&self, _target: &str) -> Result<Option<CheckoutSafety>> {
1968        // Check if there are uncommitted changes
1969        let is_dirty = self.is_dirty()?;
1970        if !is_dirty {
1971            // No uncommitted changes, checkout is safe
1972            return Ok(None);
1973        }
1974
1975        // Get current branch for context
1976        let current_branch = self.get_current_branch().ok();
1977
1978        // Get detailed information about uncommitted changes
1979        let modified_files = self.get_modified_files()?;
1980        let staged_files = self.get_staged_files()?;
1981        let untracked_files = self.get_untracked_files()?;
1982
1983        let has_uncommitted_changes = !modified_files.is_empty() || !staged_files.is_empty();
1984
1985        if has_uncommitted_changes || !untracked_files.is_empty() {
1986            return Ok(Some(CheckoutSafety {
1987                has_uncommitted_changes,
1988                modified_files,
1989                staged_files,
1990                untracked_files,
1991                stash_created: None,
1992                current_branch,
1993            }));
1994        }
1995
1996        Ok(None)
1997    }
1998
1999    /// Handle user confirmation for checkout operations with uncommitted changes
2000    fn handle_checkout_confirmation(
2001        &self,
2002        target: &str,
2003        safety_info: &CheckoutSafety,
2004    ) -> Result<()> {
2005        // Check if we're in a non-interactive environment FIRST (before any output)
2006        let is_ci = std::env::var("CI").is_ok();
2007        let no_confirm = std::env::var("CHECKOUT_NO_CONFIRM").is_ok();
2008        let is_non_interactive = is_ci || no_confirm;
2009
2010        if is_non_interactive {
2011            return Err(CascadeError::branch(
2012                format!(
2013                    "Cannot checkout '{target}' with uncommitted changes in non-interactive mode. Commit your changes or use stash first."
2014                )
2015            ));
2016        }
2017
2018        // Interactive warning and confirmation
2019        println!("\n⚠️  CHECKOUT WARNING ⚠️");
2020        println!("You have uncommitted changes that could be lost:");
2021
2022        if !safety_info.modified_files.is_empty() {
2023            println!(
2024                "\n📝 Modified files ({}):",
2025                safety_info.modified_files.len()
2026            );
2027            for file in safety_info.modified_files.iter().take(10) {
2028                println!("   - {file}");
2029            }
2030            if safety_info.modified_files.len() > 10 {
2031                println!("   ... and {} more", safety_info.modified_files.len() - 10);
2032            }
2033        }
2034
2035        if !safety_info.staged_files.is_empty() {
2036            println!("\n📁 Staged files ({}):", safety_info.staged_files.len());
2037            for file in safety_info.staged_files.iter().take(10) {
2038                println!("   - {file}");
2039            }
2040            if safety_info.staged_files.len() > 10 {
2041                println!("   ... and {} more", safety_info.staged_files.len() - 10);
2042            }
2043        }
2044
2045        if !safety_info.untracked_files.is_empty() {
2046            println!(
2047                "\n❓ Untracked files ({}):",
2048                safety_info.untracked_files.len()
2049            );
2050            for file in safety_info.untracked_files.iter().take(5) {
2051                println!("   - {file}");
2052            }
2053            if safety_info.untracked_files.len() > 5 {
2054                println!("   ... and {} more", safety_info.untracked_files.len() - 5);
2055            }
2056        }
2057
2058        println!("\n🔄 Options:");
2059        println!("1. Stash changes and checkout (recommended)");
2060        println!("2. Force checkout (WILL LOSE UNCOMMITTED CHANGES)");
2061        println!("3. Cancel checkout");
2062
2063        // Use proper selection dialog instead of y/n confirmation
2064        let selection = Select::with_theme(&ColorfulTheme::default())
2065            .with_prompt("Choose an action")
2066            .items(&[
2067                "Stash changes and checkout (recommended)",
2068                "Force checkout (WILL LOSE UNCOMMITTED CHANGES)",
2069                "Cancel checkout",
2070            ])
2071            .default(0)
2072            .interact()
2073            .map_err(|e| CascadeError::branch(format!("Could not get user selection: {e}")))?;
2074
2075        match selection {
2076            0 => {
2077                // Option 1: Stash changes and checkout
2078                let stash_message = format!(
2079                    "Auto-stash before checkout to {} at {}",
2080                    target,
2081                    chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
2082                );
2083
2084                match self.create_stash(&stash_message) {
2085                    Ok(stash_id) => {
2086                        println!("✅ Created stash: {stash_message} ({stash_id})");
2087                        println!("💡 You can restore with: git stash pop");
2088                    }
2089                    Err(e) => {
2090                        println!("❌ Failed to create stash: {e}");
2091
2092                        // If stash failed, provide better options
2093                        use dialoguer::Select;
2094                        let stash_failed_options = vec![
2095                            "Commit staged changes and proceed",
2096                            "Force checkout (WILL LOSE CHANGES)",
2097                            "Cancel and handle manually",
2098                        ];
2099
2100                        let stash_selection = Select::with_theme(&ColorfulTheme::default())
2101                            .with_prompt("Stash failed. What would you like to do?")
2102                            .items(&stash_failed_options)
2103                            .default(0)
2104                            .interact()
2105                            .map_err(|e| {
2106                                CascadeError::branch(format!("Could not get user selection: {e}"))
2107                            })?;
2108
2109                        match stash_selection {
2110                            0 => {
2111                                // Try to commit staged changes
2112                                let staged_files = self.get_staged_files()?;
2113                                if !staged_files.is_empty() {
2114                                    println!(
2115                                        "📝 Committing {} staged files...",
2116                                        staged_files.len()
2117                                    );
2118                                    match self
2119                                        .commit_staged_changes("WIP: Auto-commit before checkout")
2120                                    {
2121                                        Ok(Some(commit_hash)) => {
2122                                            println!(
2123                                                "✅ Committed staged changes as {}",
2124                                                &commit_hash[..8]
2125                                            );
2126                                            println!("💡 You can undo with: git reset HEAD~1");
2127                                        }
2128                                        Ok(None) => {
2129                                            println!("ℹ️  No staged changes found to commit");
2130                                        }
2131                                        Err(commit_err) => {
2132                                            println!(
2133                                                "❌ Failed to commit staged changes: {commit_err}"
2134                                            );
2135                                            return Err(CascadeError::branch(
2136                                                "Could not commit staged changes".to_string(),
2137                                            ));
2138                                        }
2139                                    }
2140                                } else {
2141                                    println!("ℹ️  No staged changes to commit");
2142                                }
2143                            }
2144                            1 => {
2145                                // Force checkout anyway
2146                                println!("⚠️  Proceeding with force checkout - uncommitted changes will be lost!");
2147                            }
2148                            2 => {
2149                                // Cancel
2150                                return Err(CascadeError::branch(
2151                                    "Checkout cancelled. Please handle changes manually and try again.".to_string(),
2152                                ));
2153                            }
2154                            _ => unreachable!(),
2155                        }
2156                    }
2157                }
2158            }
2159            1 => {
2160                // Option 2: Force checkout (lose changes)
2161                println!("⚠️  Proceeding with force checkout - uncommitted changes will be lost!");
2162            }
2163            2 => {
2164                // Option 3: Cancel
2165                return Err(CascadeError::branch(
2166                    "Checkout cancelled by user".to_string(),
2167                ));
2168            }
2169            _ => unreachable!(),
2170        }
2171
2172        Ok(())
2173    }
2174
2175    /// Create a stash with uncommitted changes
2176    fn create_stash(&self, message: &str) -> Result<String> {
2177        tracing::info!("Creating stash: {}", message);
2178
2179        // Use git CLI for stashing since git2 stashing is complex and unreliable
2180        let output = std::process::Command::new("git")
2181            .args(["stash", "push", "-m", message])
2182            .current_dir(&self.path)
2183            .output()
2184            .map_err(|e| {
2185                CascadeError::branch(format!("Failed to execute git stash command: {e}"))
2186            })?;
2187
2188        if output.status.success() {
2189            let stdout = String::from_utf8_lossy(&output.stdout);
2190
2191            // Extract stash hash if available (git stash outputs like "Saved working directory and index state WIP on branch: message")
2192            let stash_id = if stdout.contains("Saved working directory") {
2193                // Get the most recent stash ID
2194                let stash_list_output = std::process::Command::new("git")
2195                    .args(["stash", "list", "-n", "1", "--format=%H"])
2196                    .current_dir(&self.path)
2197                    .output()
2198                    .map_err(|e| CascadeError::branch(format!("Failed to get stash ID: {e}")))?;
2199
2200                if stash_list_output.status.success() {
2201                    String::from_utf8_lossy(&stash_list_output.stdout)
2202                        .trim()
2203                        .to_string()
2204                } else {
2205                    "stash@{0}".to_string() // fallback
2206                }
2207            } else {
2208                "stash@{0}".to_string() // fallback
2209            };
2210
2211            tracing::info!("✅ Created stash: {} ({})", message, stash_id);
2212            Ok(stash_id)
2213        } else {
2214            let stderr = String::from_utf8_lossy(&output.stderr);
2215            let stdout = String::from_utf8_lossy(&output.stdout);
2216
2217            // Check for common stash failure reasons
2218            if stderr.contains("No local changes to save")
2219                || stdout.contains("No local changes to save")
2220            {
2221                return Err(CascadeError::branch("No local changes to save".to_string()));
2222            }
2223
2224            Err(CascadeError::branch(format!(
2225                "Failed to create stash: {}\nStderr: {}\nStdout: {}",
2226                output.status, stderr, stdout
2227            )))
2228        }
2229    }
2230
2231    /// Get modified files in working directory
2232    fn get_modified_files(&self) -> Result<Vec<String>> {
2233        let mut opts = git2::StatusOptions::new();
2234        opts.include_untracked(false).include_ignored(false);
2235
2236        let statuses = self
2237            .repo
2238            .statuses(Some(&mut opts))
2239            .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
2240
2241        let mut modified_files = Vec::new();
2242        for status in statuses.iter() {
2243            let flags = status.status();
2244            if flags.contains(git2::Status::WT_MODIFIED) || flags.contains(git2::Status::WT_DELETED)
2245            {
2246                if let Some(path) = status.path() {
2247                    modified_files.push(path.to_string());
2248                }
2249            }
2250        }
2251
2252        Ok(modified_files)
2253    }
2254
2255    /// Get staged files in index
2256    pub fn get_staged_files(&self) -> Result<Vec<String>> {
2257        let mut opts = git2::StatusOptions::new();
2258        opts.include_untracked(false).include_ignored(false);
2259
2260        let statuses = self
2261            .repo
2262            .statuses(Some(&mut opts))
2263            .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
2264
2265        let mut staged_files = Vec::new();
2266        for status in statuses.iter() {
2267            let flags = status.status();
2268            if flags.contains(git2::Status::INDEX_MODIFIED)
2269                || flags.contains(git2::Status::INDEX_NEW)
2270                || flags.contains(git2::Status::INDEX_DELETED)
2271            {
2272                if let Some(path) = status.path() {
2273                    staged_files.push(path.to_string());
2274                }
2275            }
2276        }
2277
2278        Ok(staged_files)
2279    }
2280
2281    /// Count commits between two references
2282    fn count_commits_between(&self, from: &str, to: &str) -> Result<usize> {
2283        let commits = self.get_commits_between(from, to)?;
2284        Ok(commits.len())
2285    }
2286
2287    /// Resolve a reference (branch name, tag, or commit hash) to a commit
2288    pub fn resolve_reference(&self, reference: &str) -> Result<git2::Commit<'_>> {
2289        // Try to parse as commit hash first
2290        if let Ok(oid) = Oid::from_str(reference) {
2291            if let Ok(commit) = self.repo.find_commit(oid) {
2292                return Ok(commit);
2293            }
2294        }
2295
2296        // Try to resolve as a reference (branch, tag, etc.)
2297        let obj = self.repo.revparse_single(reference).map_err(|e| {
2298            CascadeError::branch(format!("Could not resolve reference '{reference}': {e}"))
2299        })?;
2300
2301        obj.peel_to_commit().map_err(|e| {
2302            CascadeError::branch(format!(
2303                "Reference '{reference}' does not point to a commit: {e}"
2304            ))
2305        })
2306    }
2307
2308    /// Reset HEAD to a specific reference (soft reset)
2309    pub fn reset_soft(&self, target_ref: &str) -> Result<()> {
2310        let target_commit = self.resolve_reference(target_ref)?;
2311
2312        self.repo
2313            .reset(target_commit.as_object(), git2::ResetType::Soft, None)
2314            .map_err(CascadeError::Git)?;
2315
2316        Ok(())
2317    }
2318
2319    /// Find which branch contains a specific commit
2320    pub fn find_branch_containing_commit(&self, commit_hash: &str) -> Result<String> {
2321        let oid = Oid::from_str(commit_hash).map_err(|e| {
2322            CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
2323        })?;
2324
2325        // Get all local branches
2326        let branches = self
2327            .repo
2328            .branches(Some(git2::BranchType::Local))
2329            .map_err(CascadeError::Git)?;
2330
2331        for branch_result in branches {
2332            let (branch, _) = branch_result.map_err(CascadeError::Git)?;
2333
2334            if let Some(branch_name) = branch.name().map_err(CascadeError::Git)? {
2335                // Check if this branch contains the commit
2336                if let Ok(branch_head) = branch.get().peel_to_commit() {
2337                    // Walk the commit history from this branch's HEAD
2338                    let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
2339                    revwalk.push(branch_head.id()).map_err(CascadeError::Git)?;
2340
2341                    for commit_oid in revwalk {
2342                        let commit_oid = commit_oid.map_err(CascadeError::Git)?;
2343                        if commit_oid == oid {
2344                            return Ok(branch_name.to_string());
2345                        }
2346                    }
2347                }
2348            }
2349        }
2350
2351        // If not found in any branch, might be on current HEAD
2352        Err(CascadeError::branch(format!(
2353            "Commit {commit_hash} not found in any local branch"
2354        )))
2355    }
2356
2357    // Async wrappers for potentially blocking operations
2358
2359    /// Fetch from remote origin (async)
2360    pub async fn fetch_async(&self) -> Result<()> {
2361        let repo_path = self.path.clone();
2362        crate::utils::async_ops::run_git_operation(move || {
2363            let repo = GitRepository::open(&repo_path)?;
2364            repo.fetch()
2365        })
2366        .await
2367    }
2368
2369    /// Pull changes from remote (async)
2370    pub async fn pull_async(&self, branch: &str) -> Result<()> {
2371        let repo_path = self.path.clone();
2372        let branch_name = branch.to_string();
2373        crate::utils::async_ops::run_git_operation(move || {
2374            let repo = GitRepository::open(&repo_path)?;
2375            repo.pull(&branch_name)
2376        })
2377        .await
2378    }
2379
2380    /// Push branch to remote (async)
2381    pub async fn push_branch_async(&self, branch_name: &str) -> Result<()> {
2382        let repo_path = self.path.clone();
2383        let branch = branch_name.to_string();
2384        crate::utils::async_ops::run_git_operation(move || {
2385            let repo = GitRepository::open(&repo_path)?;
2386            repo.push(&branch)
2387        })
2388        .await
2389    }
2390
2391    /// Cherry-pick commit (async)
2392    pub async fn cherry_pick_commit_async(&self, commit_hash: &str) -> Result<String> {
2393        let repo_path = self.path.clone();
2394        let hash = commit_hash.to_string();
2395        crate::utils::async_ops::run_git_operation(move || {
2396            let repo = GitRepository::open(&repo_path)?;
2397            repo.cherry_pick(&hash)
2398        })
2399        .await
2400    }
2401
2402    /// Get commit hashes between two refs (async)
2403    pub async fn get_commit_hashes_between_async(
2404        &self,
2405        from: &str,
2406        to: &str,
2407    ) -> Result<Vec<String>> {
2408        let repo_path = self.path.clone();
2409        let from_str = from.to_string();
2410        let to_str = to.to_string();
2411        crate::utils::async_ops::run_git_operation(move || {
2412            let repo = GitRepository::open(&repo_path)?;
2413            let commits = repo.get_commits_between(&from_str, &to_str)?;
2414            Ok(commits.into_iter().map(|c| c.id().to_string()).collect())
2415        })
2416        .await
2417    }
2418
2419    /// Reset a branch to point to a specific commit
2420    pub fn reset_branch_to_commit(&self, branch_name: &str, commit_hash: &str) -> Result<()> {
2421        info!(
2422            "Resetting branch '{}' to commit {}",
2423            branch_name,
2424            &commit_hash[..8]
2425        );
2426
2427        // Find the target commit
2428        let target_oid = git2::Oid::from_str(commit_hash).map_err(|e| {
2429            CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
2430        })?;
2431
2432        let _target_commit = self.repo.find_commit(target_oid).map_err(|e| {
2433            CascadeError::branch(format!("Could not find commit '{commit_hash}': {e}"))
2434        })?;
2435
2436        // Find the branch
2437        let _branch = self
2438            .repo
2439            .find_branch(branch_name, git2::BranchType::Local)
2440            .map_err(|e| {
2441                CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
2442            })?;
2443
2444        // Update the branch reference to point to the target commit
2445        let branch_ref_name = format!("refs/heads/{branch_name}");
2446        self.repo
2447            .reference(
2448                &branch_ref_name,
2449                target_oid,
2450                true,
2451                &format!("Reset {branch_name} to {commit_hash}"),
2452            )
2453            .map_err(|e| {
2454                CascadeError::branch(format!(
2455                    "Could not reset branch '{branch_name}' to commit '{commit_hash}': {e}"
2456                ))
2457            })?;
2458
2459        tracing::info!(
2460            "Successfully reset branch '{}' to commit {}",
2461            branch_name,
2462            &commit_hash[..8]
2463        );
2464        Ok(())
2465    }
2466}
2467
2468#[cfg(test)]
2469mod tests {
2470    use super::*;
2471    use std::process::Command;
2472    use tempfile::TempDir;
2473
2474    fn create_test_repo() -> (TempDir, PathBuf) {
2475        let temp_dir = TempDir::new().unwrap();
2476        let repo_path = temp_dir.path().to_path_buf();
2477
2478        // Initialize git repository
2479        Command::new("git")
2480            .args(["init"])
2481            .current_dir(&repo_path)
2482            .output()
2483            .unwrap();
2484        Command::new("git")
2485            .args(["config", "user.name", "Test"])
2486            .current_dir(&repo_path)
2487            .output()
2488            .unwrap();
2489        Command::new("git")
2490            .args(["config", "user.email", "test@test.com"])
2491            .current_dir(&repo_path)
2492            .output()
2493            .unwrap();
2494
2495        // Create initial commit
2496        std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
2497        Command::new("git")
2498            .args(["add", "."])
2499            .current_dir(&repo_path)
2500            .output()
2501            .unwrap();
2502        Command::new("git")
2503            .args(["commit", "-m", "Initial commit"])
2504            .current_dir(&repo_path)
2505            .output()
2506            .unwrap();
2507
2508        (temp_dir, repo_path)
2509    }
2510
2511    fn create_commit(repo_path: &PathBuf, message: &str, filename: &str) {
2512        let file_path = repo_path.join(filename);
2513        std::fs::write(&file_path, format!("Content for {filename}\n")).unwrap();
2514
2515        Command::new("git")
2516            .args(["add", filename])
2517            .current_dir(repo_path)
2518            .output()
2519            .unwrap();
2520        Command::new("git")
2521            .args(["commit", "-m", message])
2522            .current_dir(repo_path)
2523            .output()
2524            .unwrap();
2525    }
2526
2527    #[test]
2528    fn test_repository_info() {
2529        let (_temp_dir, repo_path) = create_test_repo();
2530        let repo = GitRepository::open(&repo_path).unwrap();
2531
2532        let info = repo.get_info().unwrap();
2533        assert!(!info.is_dirty); // Should be clean after commit
2534        assert!(
2535            info.head_branch == Some("master".to_string())
2536                || info.head_branch == Some("main".to_string()),
2537            "Expected default branch to be 'master' or 'main', got {:?}",
2538            info.head_branch
2539        );
2540        assert!(info.head_commit.is_some()); // Just check it exists
2541        assert!(info.untracked_files.is_empty()); // Should be empty after commit
2542    }
2543
2544    #[test]
2545    fn test_force_push_branch_basic() {
2546        let (_temp_dir, repo_path) = create_test_repo();
2547        let repo = GitRepository::open(&repo_path).unwrap();
2548
2549        // Get the actual default branch name
2550        let default_branch = repo.get_current_branch().unwrap();
2551
2552        // Create source branch with commits
2553        create_commit(&repo_path, "Feature commit 1", "feature1.rs");
2554        Command::new("git")
2555            .args(["checkout", "-b", "source-branch"])
2556            .current_dir(&repo_path)
2557            .output()
2558            .unwrap();
2559        create_commit(&repo_path, "Feature commit 2", "feature2.rs");
2560
2561        // Create target branch
2562        Command::new("git")
2563            .args(["checkout", &default_branch])
2564            .current_dir(&repo_path)
2565            .output()
2566            .unwrap();
2567        Command::new("git")
2568            .args(["checkout", "-b", "target-branch"])
2569            .current_dir(&repo_path)
2570            .output()
2571            .unwrap();
2572        create_commit(&repo_path, "Target commit", "target.rs");
2573
2574        // Test force push from source to target
2575        let result = repo.force_push_branch("target-branch", "source-branch");
2576
2577        // Should succeed in test environment (even though it doesn't actually push to remote)
2578        // The important thing is that the function doesn't panic and handles the git2 operations
2579        assert!(result.is_ok() || result.is_err()); // Either is acceptable for unit test
2580    }
2581
2582    #[test]
2583    fn test_force_push_branch_nonexistent_branches() {
2584        let (_temp_dir, repo_path) = create_test_repo();
2585        let repo = GitRepository::open(&repo_path).unwrap();
2586
2587        // Get the actual default branch name
2588        let default_branch = repo.get_current_branch().unwrap();
2589
2590        // Test force push with nonexistent source branch
2591        let result = repo.force_push_branch("target", "nonexistent-source");
2592        assert!(result.is_err());
2593
2594        // Test force push with nonexistent target branch
2595        let result = repo.force_push_branch("nonexistent-target", &default_branch);
2596        assert!(result.is_err());
2597    }
2598
2599    #[test]
2600    fn test_force_push_workflow_simulation() {
2601        let (_temp_dir, repo_path) = create_test_repo();
2602        let repo = GitRepository::open(&repo_path).unwrap();
2603
2604        // Simulate the smart force push workflow:
2605        // 1. Original branch exists with PR
2606        Command::new("git")
2607            .args(["checkout", "-b", "feature-auth"])
2608            .current_dir(&repo_path)
2609            .output()
2610            .unwrap();
2611        create_commit(&repo_path, "Add authentication", "auth.rs");
2612
2613        // 2. Rebase creates versioned branch
2614        Command::new("git")
2615            .args(["checkout", "-b", "feature-auth-v2"])
2616            .current_dir(&repo_path)
2617            .output()
2618            .unwrap();
2619        create_commit(&repo_path, "Fix auth validation", "auth.rs");
2620
2621        // 3. Smart force push: update original branch from versioned branch
2622        let result = repo.force_push_branch("feature-auth", "feature-auth-v2");
2623
2624        // Verify the operation is handled properly (success or expected error)
2625        match result {
2626            Ok(_) => {
2627                // Force push succeeded - verify branch state if possible
2628                Command::new("git")
2629                    .args(["checkout", "feature-auth"])
2630                    .current_dir(&repo_path)
2631                    .output()
2632                    .unwrap();
2633                let log_output = Command::new("git")
2634                    .args(["log", "--oneline", "-2"])
2635                    .current_dir(&repo_path)
2636                    .output()
2637                    .unwrap();
2638                let log_str = String::from_utf8_lossy(&log_output.stdout);
2639                assert!(
2640                    log_str.contains("Fix auth validation")
2641                        || log_str.contains("Add authentication")
2642                );
2643            }
2644            Err(_) => {
2645                // Expected in test environment without remote - that's fine
2646                // The important thing is we tested the code path without panicking
2647            }
2648        }
2649    }
2650
2651    #[test]
2652    fn test_branch_operations() {
2653        let (_temp_dir, repo_path) = create_test_repo();
2654        let repo = GitRepository::open(&repo_path).unwrap();
2655
2656        // Test get current branch - accept either main or master
2657        let current = repo.get_current_branch().unwrap();
2658        assert!(
2659            current == "master" || current == "main",
2660            "Expected default branch to be 'master' or 'main', got '{current}'"
2661        );
2662
2663        // Test create branch
2664        Command::new("git")
2665            .args(["checkout", "-b", "test-branch"])
2666            .current_dir(&repo_path)
2667            .output()
2668            .unwrap();
2669        let current = repo.get_current_branch().unwrap();
2670        assert_eq!(current, "test-branch");
2671    }
2672
2673    #[test]
2674    fn test_commit_operations() {
2675        let (_temp_dir, repo_path) = create_test_repo();
2676        let repo = GitRepository::open(&repo_path).unwrap();
2677
2678        // Test get head commit
2679        let head = repo.get_head_commit().unwrap();
2680        assert_eq!(head.message().unwrap().trim(), "Initial commit");
2681
2682        // Test get commit by hash
2683        let hash = head.id().to_string();
2684        let same_commit = repo.get_commit(&hash).unwrap();
2685        assert_eq!(head.id(), same_commit.id());
2686    }
2687
2688    #[test]
2689    fn test_checkout_safety_clean_repo() {
2690        let (_temp_dir, repo_path) = create_test_repo();
2691        let repo = GitRepository::open(&repo_path).unwrap();
2692
2693        // Create a test branch
2694        create_commit(&repo_path, "Second commit", "test.txt");
2695        Command::new("git")
2696            .args(["checkout", "-b", "test-branch"])
2697            .current_dir(&repo_path)
2698            .output()
2699            .unwrap();
2700
2701        // Test checkout safety with clean repo
2702        let safety_result = repo.check_checkout_safety("main");
2703        assert!(safety_result.is_ok());
2704        assert!(safety_result.unwrap().is_none()); // Clean repo should return None
2705    }
2706
2707    #[test]
2708    fn test_checkout_safety_with_modified_files() {
2709        let (_temp_dir, repo_path) = create_test_repo();
2710        let repo = GitRepository::open(&repo_path).unwrap();
2711
2712        // Create a test branch
2713        Command::new("git")
2714            .args(["checkout", "-b", "test-branch"])
2715            .current_dir(&repo_path)
2716            .output()
2717            .unwrap();
2718
2719        // Modify a file to create uncommitted changes
2720        std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2721
2722        // Test checkout safety with modified files
2723        let safety_result = repo.check_checkout_safety("main");
2724        assert!(safety_result.is_ok());
2725        let safety_info = safety_result.unwrap();
2726        assert!(safety_info.is_some());
2727
2728        let info = safety_info.unwrap();
2729        assert!(!info.modified_files.is_empty());
2730        assert!(info.modified_files.contains(&"README.md".to_string()));
2731    }
2732
2733    #[test]
2734    fn test_unsafe_checkout_methods() {
2735        let (_temp_dir, repo_path) = create_test_repo();
2736        let repo = GitRepository::open(&repo_path).unwrap();
2737
2738        // Create a test branch
2739        create_commit(&repo_path, "Second commit", "test.txt");
2740        Command::new("git")
2741            .args(["checkout", "-b", "test-branch"])
2742            .current_dir(&repo_path)
2743            .output()
2744            .unwrap();
2745
2746        // Modify a file to create uncommitted changes
2747        std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2748
2749        // Test unsafe checkout methods bypass safety checks
2750        let _result = repo.checkout_branch_unsafe("master");
2751        // Note: This might still fail due to git2 restrictions, but shouldn't hit our safety code
2752        // The important thing is that it doesn't trigger our safety confirmation
2753
2754        // Test unsafe commit checkout
2755        let head_commit = repo.get_head_commit().unwrap();
2756        let commit_hash = head_commit.id().to_string();
2757        let _result = repo.checkout_commit_unsafe(&commit_hash);
2758        // Similar to above - testing that safety is bypassed
2759    }
2760
2761    #[test]
2762    fn test_get_modified_files() {
2763        let (_temp_dir, repo_path) = create_test_repo();
2764        let repo = GitRepository::open(&repo_path).unwrap();
2765
2766        // Initially should have no modified files
2767        let modified = repo.get_modified_files().unwrap();
2768        assert!(modified.is_empty());
2769
2770        // Modify a file
2771        std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2772
2773        // Should now detect the modified file
2774        let modified = repo.get_modified_files().unwrap();
2775        assert_eq!(modified.len(), 1);
2776        assert!(modified.contains(&"README.md".to_string()));
2777    }
2778
2779    #[test]
2780    fn test_get_staged_files() {
2781        let (_temp_dir, repo_path) = create_test_repo();
2782        let repo = GitRepository::open(&repo_path).unwrap();
2783
2784        // Initially should have no staged files
2785        let staged = repo.get_staged_files().unwrap();
2786        assert!(staged.is_empty());
2787
2788        // Create and stage a new file
2789        std::fs::write(repo_path.join("staged.txt"), "Staged content").unwrap();
2790        Command::new("git")
2791            .args(["add", "staged.txt"])
2792            .current_dir(&repo_path)
2793            .output()
2794            .unwrap();
2795
2796        // Should now detect the staged file
2797        let staged = repo.get_staged_files().unwrap();
2798        assert_eq!(staged.len(), 1);
2799        assert!(staged.contains(&"staged.txt".to_string()));
2800    }
2801
2802    #[test]
2803    fn test_create_stash_fallback() {
2804        let (_temp_dir, repo_path) = create_test_repo();
2805        let repo = GitRepository::open(&repo_path).unwrap();
2806
2807        // Test stash creation - newer git versions allow empty stashes
2808        let result = repo.create_stash("test stash");
2809
2810        // Either succeeds (newer git with empty stash) or fails with helpful message
2811        match result {
2812            Ok(stash_id) => {
2813                // Modern git allows empty stashes, verify we got a stash ID
2814                assert!(!stash_id.is_empty());
2815                assert!(stash_id.contains("stash") || stash_id.len() >= 7); // SHA or stash@{n}
2816            }
2817            Err(error) => {
2818                // Older git should fail with helpful message
2819                let error_msg = error.to_string();
2820                assert!(
2821                    error_msg.contains("No local changes to save")
2822                        || error_msg.contains("git stash push")
2823                );
2824            }
2825        }
2826    }
2827
2828    #[test]
2829    fn test_delete_branch_unsafe() {
2830        let (_temp_dir, repo_path) = create_test_repo();
2831        let repo = GitRepository::open(&repo_path).unwrap();
2832
2833        // Create a test branch
2834        create_commit(&repo_path, "Second commit", "test.txt");
2835        Command::new("git")
2836            .args(["checkout", "-b", "test-branch"])
2837            .current_dir(&repo_path)
2838            .output()
2839            .unwrap();
2840
2841        // Add another commit to the test branch to make it different from master
2842        create_commit(&repo_path, "Branch-specific commit", "branch.txt");
2843
2844        // Go back to master
2845        Command::new("git")
2846            .args(["checkout", "master"])
2847            .current_dir(&repo_path)
2848            .output()
2849            .unwrap();
2850
2851        // Test unsafe delete bypasses safety checks
2852        // Note: This may still fail if the branch has unpushed commits, but it should bypass our safety confirmation
2853        let result = repo.delete_branch_unsafe("test-branch");
2854        // Even if it fails, the key is that it didn't prompt for user confirmation
2855        // So we just check that it attempted the operation without interactive prompts
2856        let _ = result; // Don't assert success since delete may fail for git reasons
2857    }
2858
2859    #[test]
2860    fn test_force_push_unsafe() {
2861        let (_temp_dir, repo_path) = create_test_repo();
2862        let repo = GitRepository::open(&repo_path).unwrap();
2863
2864        // Create a test branch
2865        create_commit(&repo_path, "Second commit", "test.txt");
2866        Command::new("git")
2867            .args(["checkout", "-b", "test-branch"])
2868            .current_dir(&repo_path)
2869            .output()
2870            .unwrap();
2871
2872        // Test unsafe force push bypasses safety checks
2873        // Note: This will likely fail due to no remote, but it tests the safety bypass
2874        let _result = repo.force_push_branch_unsafe("test-branch", "test-branch");
2875        // The key is that it doesn't trigger safety confirmation dialogs
2876    }
2877}