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::{debug, 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/// Summary of git repository status
59#[derive(Debug, Clone)]
60pub struct GitStatusSummary {
61    staged_files: usize,
62    unstaged_files: usize,
63    untracked_files: usize,
64}
65
66impl GitStatusSummary {
67    pub fn is_clean(&self) -> bool {
68        self.staged_files == 0 && self.unstaged_files == 0 && self.untracked_files == 0
69    }
70
71    pub fn has_staged_changes(&self) -> bool {
72        self.staged_files > 0
73    }
74
75    pub fn has_unstaged_changes(&self) -> bool {
76        self.unstaged_files > 0
77    }
78
79    pub fn has_untracked_files(&self) -> bool {
80        self.untracked_files > 0
81    }
82
83    pub fn staged_count(&self) -> usize {
84        self.staged_files
85    }
86
87    pub fn unstaged_count(&self) -> usize {
88        self.unstaged_files
89    }
90
91    pub fn untracked_count(&self) -> usize {
92        self.untracked_files
93    }
94}
95
96/// Wrapper around git2::Repository with safe operations
97///
98/// For thread safety, use the async variants (e.g., fetch_async, pull_async)
99/// which automatically handle threading using tokio::spawn_blocking.
100/// The async methods create new repository instances in background threads.
101pub struct GitRepository {
102    repo: Repository,
103    path: PathBuf,
104    ssl_config: Option<GitSslConfig>,
105    bitbucket_credentials: Option<BitbucketCredentials>,
106}
107
108#[derive(Debug, Clone)]
109struct BitbucketCredentials {
110    username: Option<String>,
111    token: Option<String>,
112}
113
114impl GitRepository {
115    /// Open a Git repository at the given path
116    /// Automatically loads SSL configuration from cascade config if available
117    pub fn open(path: &Path) -> Result<Self> {
118        let repo = Repository::discover(path)
119            .map_err(|e| CascadeError::config(format!("Not a git repository: {e}")))?;
120
121        let workdir = repo
122            .workdir()
123            .ok_or_else(|| CascadeError::config("Repository has no working directory"))?
124            .to_path_buf();
125
126        // Try to load SSL configuration from cascade config
127        let ssl_config = Self::load_ssl_config_from_cascade(&workdir);
128        let bitbucket_credentials = Self::load_bitbucket_credentials_from_cascade(&workdir);
129
130        Ok(Self {
131            repo,
132            path: workdir,
133            ssl_config,
134            bitbucket_credentials,
135        })
136    }
137
138    /// Load SSL configuration from cascade config file if it exists
139    fn load_ssl_config_from_cascade(repo_path: &Path) -> Option<GitSslConfig> {
140        // Try to load cascade configuration
141        let config_dir = crate::config::get_repo_config_dir(repo_path).ok()?;
142        let config_path = config_dir.join("config.json");
143        let settings = crate::config::Settings::load_from_file(&config_path).ok()?;
144
145        // Convert BitbucketConfig to GitSslConfig if SSL settings exist
146        if settings.bitbucket.accept_invalid_certs.is_some()
147            || settings.bitbucket.ca_bundle_path.is_some()
148        {
149            Some(GitSslConfig {
150                accept_invalid_certs: settings.bitbucket.accept_invalid_certs.unwrap_or(false),
151                ca_bundle_path: settings.bitbucket.ca_bundle_path,
152            })
153        } else {
154            None
155        }
156    }
157
158    /// Load Bitbucket credentials from cascade config file if it exists
159    fn load_bitbucket_credentials_from_cascade(repo_path: &Path) -> Option<BitbucketCredentials> {
160        // Try to load cascade configuration
161        let config_dir = crate::config::get_repo_config_dir(repo_path).ok()?;
162        let config_path = config_dir.join("config.json");
163        let settings = crate::config::Settings::load_from_file(&config_path).ok()?;
164
165        // Return credentials if any are configured
166        if settings.bitbucket.username.is_some() || settings.bitbucket.token.is_some() {
167            Some(BitbucketCredentials {
168                username: settings.bitbucket.username.clone(),
169                token: settings.bitbucket.token.clone(),
170            })
171        } else {
172            None
173        }
174    }
175
176    /// Get repository information
177    pub fn get_info(&self) -> Result<RepositoryInfo> {
178        let head_branch = self.get_current_branch().ok();
179        let head_commit = self.get_head_commit_hash().ok();
180        let is_dirty = self.is_dirty()?;
181        let untracked_files = self.get_untracked_files()?;
182
183        Ok(RepositoryInfo {
184            path: self.path.clone(),
185            head_branch,
186            head_commit,
187            is_dirty,
188            untracked_files,
189        })
190    }
191
192    /// Get the current branch name
193    pub fn get_current_branch(&self) -> Result<String> {
194        let head = self
195            .repo
196            .head()
197            .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
198
199        if let Some(name) = head.shorthand() {
200            Ok(name.to_string())
201        } else {
202            // Detached HEAD - return commit hash
203            let commit = head
204                .peel_to_commit()
205                .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))?;
206            Ok(format!("HEAD@{}", commit.id()))
207        }
208    }
209
210    /// Get the HEAD commit hash
211    pub fn get_head_commit_hash(&self) -> Result<String> {
212        let head = self
213            .repo
214            .head()
215            .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
216
217        let commit = head
218            .peel_to_commit()
219            .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))?;
220
221        Ok(commit.id().to_string())
222    }
223
224    /// Check if the working directory is dirty (has uncommitted changes)
225    /// Excludes .cascade/ directory changes as these are internal metadata
226    pub fn is_dirty(&self) -> Result<bool> {
227        let statuses = self.repo.statuses(None).map_err(CascadeError::Git)?;
228
229        for status in statuses.iter() {
230            let flags = status.status();
231
232            // Skip .cascade/ directory - it's internal metadata that shouldn't block operations
233            if let Some(path) = status.path() {
234                if path.starts_with(".cascade/") || path == ".cascade" {
235                    continue;
236                }
237            }
238
239            // Check for any modifications, additions, or deletions
240            if flags.intersects(
241                git2::Status::INDEX_MODIFIED
242                    | git2::Status::INDEX_NEW
243                    | git2::Status::INDEX_DELETED
244                    | git2::Status::WT_MODIFIED
245                    | git2::Status::WT_NEW
246                    | git2::Status::WT_DELETED,
247            ) {
248                return Ok(true);
249            }
250        }
251
252        Ok(false)
253    }
254
255    /// Get list of untracked files
256    pub fn get_untracked_files(&self) -> Result<Vec<String>> {
257        let statuses = self.repo.statuses(None).map_err(CascadeError::Git)?;
258
259        let mut untracked = Vec::new();
260        for status in statuses.iter() {
261            if status.status().contains(git2::Status::WT_NEW) {
262                if let Some(path) = status.path() {
263                    untracked.push(path.to_string());
264                }
265            }
266        }
267
268        Ok(untracked)
269    }
270
271    /// Create a new branch
272    pub fn create_branch(&self, name: &str, target: Option<&str>) -> Result<()> {
273        let target_commit = if let Some(target) = target {
274            // Find the specified target commit/branch
275            let target_obj = self.repo.revparse_single(target).map_err(|e| {
276                CascadeError::branch(format!("Could not find target '{target}': {e}"))
277            })?;
278            target_obj.peel_to_commit().map_err(|e| {
279                CascadeError::branch(format!("Target '{target}' is not a commit: {e}"))
280            })?
281        } else {
282            // Use current HEAD
283            let head = self
284                .repo
285                .head()
286                .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
287            head.peel_to_commit()
288                .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))?
289        };
290
291        self.repo
292            .branch(name, &target_commit, false)
293            .map_err(|e| CascadeError::branch(format!("Could not create branch '{name}': {e}")))?;
294
295        // Branch creation logging is handled by the caller for clean output
296        Ok(())
297    }
298
299    /// Update a branch to point to a specific commit (local operation only)
300    /// Creates the branch if it doesn't exist, updates it if it does
301    pub fn update_branch_to_commit(&self, branch_name: &str, commit_id: &str) -> Result<()> {
302        let commit_oid = Oid::from_str(commit_id).map_err(|e| {
303            CascadeError::branch(format!("Invalid commit ID '{}': {}", commit_id, e))
304        })?;
305
306        let commit = self.repo.find_commit(commit_oid).map_err(|e| {
307            CascadeError::branch(format!("Commit '{}' not found: {}", commit_id, e))
308        })?;
309
310        // Try to find existing branch
311        if self
312            .repo
313            .find_branch(branch_name, git2::BranchType::Local)
314            .is_ok()
315        {
316            // Update existing branch to point to new commit
317            let refname = format!("refs/heads/{}", branch_name);
318            self.repo
319                .reference(
320                    &refname,
321                    commit_oid,
322                    true,
323                    "update branch to rebased commit",
324                )
325                .map_err(|e| {
326                    CascadeError::branch(format!(
327                        "Failed to update branch '{}': {}",
328                        branch_name, e
329                    ))
330                })?;
331        } else {
332            // Create new branch
333            self.repo.branch(branch_name, &commit, false).map_err(|e| {
334                CascadeError::branch(format!("Failed to create branch '{}': {}", branch_name, e))
335            })?;
336        }
337
338        Ok(())
339    }
340
341    /// Force-push a single branch to remote (simpler version for when branch is already updated locally)
342    pub fn force_push_single_branch(&self, branch_name: &str) -> Result<()> {
343        self.force_push_single_branch_with_options(branch_name, false, false)
344    }
345
346    /// Force push with option to skip user confirmation (for automated operations like sync)
347    pub fn force_push_single_branch_auto(&self, branch_name: &str) -> Result<()> {
348        self.force_push_single_branch_with_options(branch_name, true, false)
349    }
350
351    /// Force push a single branch without fetching first (assumes fetch already done)
352    /// Used in batch operations where we fetch once before pushing multiple branches
353    pub fn force_push_single_branch_auto_no_fetch(&self, branch_name: &str) -> Result<()> {
354        self.force_push_single_branch_with_options(branch_name, true, true)
355    }
356
357    fn force_push_single_branch_with_options(
358        &self,
359        branch_name: &str,
360        auto_confirm: bool,
361        skip_fetch: bool,
362    ) -> Result<()> {
363        // Validate branch exists before attempting push
364        // This provides a clearer error message than a failed git push
365        if self.get_branch_commit_hash(branch_name).is_err() {
366            return Err(CascadeError::branch(format!(
367                "Cannot push '{}': branch does not exist locally",
368                branch_name
369            )));
370        }
371
372        // CRITICAL: Fetch with retry to ensure we have latest remote state
373        // Using stale refs could cause silent data loss on force push!
374        // Skip if caller already fetched (batch operations)
375        if !skip_fetch {
376            self.fetch_with_retry()?;
377        }
378
379        // Check safety and create backup if needed
380        let safety_result = if auto_confirm {
381            self.check_force_push_safety_auto_no_fetch(branch_name)?
382        } else {
383            self.check_force_push_safety_enhanced(branch_name)?
384        };
385
386        if let Some(backup_info) = safety_result {
387            self.create_backup_branch(branch_name, &backup_info.remote_commit_id)?;
388        }
389
390        // Ensure index is closed before CLI command to prevent lock conflicts
391        self.ensure_index_closed()?;
392
393        // Create marker file to signal pre-push hook to allow this internal push
394        // (Git hooks don't inherit env vars, so we use a file marker instead)
395        let marker_path = self.path.join(".git").join(".cascade-internal-push");
396        std::fs::write(&marker_path, "1")
397            .map_err(|e| CascadeError::branch(format!("Failed to create push marker: {}", e)))?;
398
399        // Force push using git CLI (more reliable than git2 for TLS)
400        let output = std::process::Command::new("git")
401            .args(["push", "--force", "origin", branch_name])
402            .current_dir(&self.path)
403            .output()
404            .map_err(|e| {
405                // Clean up marker on error
406                let _ = std::fs::remove_file(&marker_path);
407                CascadeError::branch(format!("Failed to execute git push: {}", e))
408            })?;
409
410        // Clean up marker file after push attempt
411        let _ = std::fs::remove_file(&marker_path);
412
413        if !output.status.success() {
414            let stderr = String::from_utf8_lossy(&output.stderr);
415            let stdout = String::from_utf8_lossy(&output.stdout);
416
417            // Combine stderr and stdout for full error context
418            let full_error = if !stdout.is_empty() {
419                format!("{}\n{}", stderr.trim(), stdout.trim())
420            } else {
421                stderr.trim().to_string()
422            };
423
424            return Err(CascadeError::branch(format!(
425                "Force push failed for '{}':\n{}",
426                branch_name, full_error
427            )));
428        }
429
430        Ok(())
431    }
432
433    /// Switch to a branch with safety checks
434    pub fn checkout_branch(&self, name: &str) -> Result<()> {
435        self.checkout_branch_with_options(name, false, true)
436    }
437
438    /// Switch to a branch silently (no output)
439    pub fn checkout_branch_silent(&self, name: &str) -> Result<()> {
440        self.checkout_branch_with_options(name, false, false)
441    }
442
443    /// Switch to a branch with force option to bypass safety checks
444    pub fn checkout_branch_unsafe(&self, name: &str) -> Result<()> {
445        self.checkout_branch_with_options(name, true, false)
446    }
447
448    /// Internal branch checkout implementation with safety options
449    fn checkout_branch_with_options(
450        &self,
451        name: &str,
452        force_unsafe: bool,
453        show_output: bool,
454    ) -> Result<()> {
455        debug!("Attempting to checkout branch: {}", name);
456
457        // Enhanced safety check: Detect uncommitted work before checkout
458        if !force_unsafe {
459            let safety_result = self.check_checkout_safety(name)?;
460            if let Some(safety_info) = safety_result {
461                // Repository has uncommitted changes, get user confirmation
462                self.handle_checkout_confirmation(name, &safety_info)?;
463            }
464        }
465
466        // Find the branch
467        let branch = self
468            .repo
469            .find_branch(name, git2::BranchType::Local)
470            .map_err(|e| CascadeError::branch(format!("Could not find branch '{name}': {e}")))?;
471
472        let branch_ref = branch.get();
473        let tree = branch_ref.peel_to_tree().map_err(|e| {
474            CascadeError::branch(format!("Could not get tree for branch '{name}': {e}"))
475        })?;
476
477        // Checkout the tree with proper options
478        // Force overwrite to ensure working directory matches branch exactly
479        let mut checkout_builder = git2::build::CheckoutBuilder::new();
480        checkout_builder.force(); // Overwrite modified files
481        checkout_builder.remove_untracked(false); // Keep untracked files
482
483        self.repo
484            .checkout_tree(tree.as_object(), Some(&mut checkout_builder))
485            .map_err(|e| {
486                CascadeError::branch(format!("Could not checkout branch '{name}': {e}"))
487            })?;
488
489        // Update HEAD
490        self.repo
491            .set_head(&format!("refs/heads/{name}"))
492            .map_err(|e| CascadeError::branch(format!("Could not update HEAD to '{name}': {e}")))?;
493
494        if show_output {
495            Output::success(format!("Switched to branch '{name}'"));
496        }
497        Ok(())
498    }
499
500    /// Checkout a specific commit (detached HEAD) with safety checks
501    pub fn checkout_commit(&self, commit_hash: &str) -> Result<()> {
502        self.checkout_commit_with_options(commit_hash, false)
503    }
504
505    /// Checkout a specific commit with force option to bypass safety checks
506    pub fn checkout_commit_unsafe(&self, commit_hash: &str) -> Result<()> {
507        self.checkout_commit_with_options(commit_hash, true)
508    }
509
510    /// Internal commit checkout implementation with safety options
511    fn checkout_commit_with_options(&self, commit_hash: &str, force_unsafe: bool) -> Result<()> {
512        debug!("Attempting to checkout commit: {}", commit_hash);
513
514        // Enhanced safety check: Detect uncommitted work before checkout
515        if !force_unsafe {
516            let safety_result = self.check_checkout_safety(&format!("commit:{commit_hash}"))?;
517            if let Some(safety_info) = safety_result {
518                // Repository has uncommitted changes, get user confirmation
519                self.handle_checkout_confirmation(&format!("commit {commit_hash}"), &safety_info)?;
520            }
521        }
522
523        let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
524
525        let commit = self.repo.find_commit(oid).map_err(|e| {
526            CascadeError::branch(format!("Could not find commit '{commit_hash}': {e}"))
527        })?;
528
529        let tree = commit.tree().map_err(|e| {
530            CascadeError::branch(format!(
531                "Could not get tree for commit '{commit_hash}': {e}"
532            ))
533        })?;
534
535        // Checkout the tree
536        self.repo
537            .checkout_tree(tree.as_object(), None)
538            .map_err(|e| {
539                CascadeError::branch(format!("Could not checkout commit '{commit_hash}': {e}"))
540            })?;
541
542        // Update HEAD to the commit (detached HEAD)
543        self.repo.set_head_detached(oid).map_err(|e| {
544            CascadeError::branch(format!(
545                "Could not update HEAD to commit '{commit_hash}': {e}"
546            ))
547        })?;
548
549        Output::success(format!(
550            "Checked out commit '{commit_hash}' (detached HEAD)"
551        ));
552        Ok(())
553    }
554
555    /// Check if a branch exists
556    pub fn branch_exists(&self, name: &str) -> bool {
557        self.repo.find_branch(name, git2::BranchType::Local).is_ok()
558    }
559
560    /// Check if a branch exists locally, and if not, attempt to fetch it from remote
561    pub fn branch_exists_or_fetch(&self, name: &str) -> Result<bool> {
562        // 1. Check if branch exists locally first
563        if self.repo.find_branch(name, git2::BranchType::Local).is_ok() {
564            return Ok(true);
565        }
566
567        // 2. Try to fetch it from remote
568        crate::cli::output::Output::info(format!(
569            "Branch '{name}' not found locally, trying to fetch from remote..."
570        ));
571
572        use std::process::Command;
573
574        // Try: git fetch origin release/12.34:release/12.34
575        let fetch_result = Command::new("git")
576            .args(["fetch", "origin", &format!("{name}:{name}")])
577            .current_dir(&self.path)
578            .output();
579
580        match fetch_result {
581            Ok(output) => {
582                if output.status.success() {
583                    println!("✅ Successfully fetched '{name}' from origin");
584                    // 3. Check again locally after fetch
585                    return Ok(self.repo.find_branch(name, git2::BranchType::Local).is_ok());
586                } else {
587                    let stderr = String::from_utf8_lossy(&output.stderr);
588                    tracing::debug!("Failed to fetch branch '{name}': {stderr}");
589                }
590            }
591            Err(e) => {
592                tracing::debug!("Git fetch command failed: {e}");
593            }
594        }
595
596        // 4. Try alternative fetch patterns for common branch naming
597        if name.contains('/') {
598            crate::cli::output::Output::info("Trying alternative fetch patterns...");
599
600            // Try: git fetch origin (to get all refs, then checkout locally)
601            let fetch_all_result = Command::new("git")
602                .args(["fetch", "origin"])
603                .current_dir(&self.path)
604                .output();
605
606            if let Ok(output) = fetch_all_result {
607                if output.status.success() {
608                    // Try to create local branch from remote
609                    let checkout_result = Command::new("git")
610                        .args(["checkout", "-b", name, &format!("origin/{name}")])
611                        .current_dir(&self.path)
612                        .output();
613
614                    if let Ok(checkout_output) = checkout_result {
615                        if checkout_output.status.success() {
616                            println!(
617                                "✅ Successfully created local branch '{name}' from origin/{name}"
618                            );
619                            return Ok(true);
620                        }
621                    }
622                }
623            }
624        }
625
626        // 5. Only fail if it doesn't exist anywhere
627        Ok(false)
628    }
629
630    /// Get the commit hash for a specific branch without switching branches
631    pub fn get_branch_commit_hash(&self, branch_name: &str) -> Result<String> {
632        let branch = self
633            .repo
634            .find_branch(branch_name, git2::BranchType::Local)
635            .map_err(|e| {
636                CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
637            })?;
638
639        let commit = branch.get().peel_to_commit().map_err(|e| {
640            CascadeError::branch(format!(
641                "Could not get commit for branch '{branch_name}': {e}"
642            ))
643        })?;
644
645        Ok(commit.id().to_string())
646    }
647
648    /// List all local branches
649    pub fn list_branches(&self) -> Result<Vec<String>> {
650        let branches = self
651            .repo
652            .branches(Some(git2::BranchType::Local))
653            .map_err(CascadeError::Git)?;
654
655        let mut branch_names = Vec::new();
656        for branch in branches {
657            let (branch, _) = branch.map_err(CascadeError::Git)?;
658            if let Some(name) = branch.name().map_err(CascadeError::Git)? {
659                branch_names.push(name.to_string());
660            }
661        }
662
663        Ok(branch_names)
664    }
665
666    /// Get the upstream branch for a local branch
667    pub fn get_upstream_branch(&self, branch_name: &str) -> Result<Option<String>> {
668        // Try to get the upstream from git config
669        let config = self.repo.config().map_err(CascadeError::Git)?;
670
671        // Check for branch.{branch_name}.remote and branch.{branch_name}.merge
672        let remote_key = format!("branch.{branch_name}.remote");
673        let merge_key = format!("branch.{branch_name}.merge");
674
675        if let (Ok(remote), Ok(merge_ref)) = (
676            config.get_string(&remote_key),
677            config.get_string(&merge_key),
678        ) {
679            // Parse the merge ref (e.g., "refs/heads/feature-auth" -> "feature-auth")
680            if let Some(branch_part) = merge_ref.strip_prefix("refs/heads/") {
681                return Ok(Some(format!("{remote}/{branch_part}")));
682            }
683        }
684
685        // Fallback: check if there's a remote tracking branch with the same name
686        let potential_upstream = format!("origin/{branch_name}");
687        if self
688            .repo
689            .find_reference(&format!("refs/remotes/{potential_upstream}"))
690            .is_ok()
691        {
692            return Ok(Some(potential_upstream));
693        }
694
695        Ok(None)
696    }
697
698    /// Get ahead/behind counts compared to upstream
699    pub fn get_ahead_behind_counts(
700        &self,
701        local_branch: &str,
702        upstream_branch: &str,
703    ) -> Result<(usize, usize)> {
704        // Get the commit objects for both branches
705        let local_ref = self
706            .repo
707            .find_reference(&format!("refs/heads/{local_branch}"))
708            .map_err(|_| {
709                CascadeError::config(format!("Local branch '{local_branch}' not found"))
710            })?;
711        let local_commit = local_ref.peel_to_commit().map_err(CascadeError::Git)?;
712
713        let upstream_ref = self
714            .repo
715            .find_reference(&format!("refs/remotes/{upstream_branch}"))
716            .map_err(|_| {
717                CascadeError::config(format!("Upstream branch '{upstream_branch}' not found"))
718            })?;
719        let upstream_commit = upstream_ref.peel_to_commit().map_err(CascadeError::Git)?;
720
721        // Use git2's graph_ahead_behind to calculate the counts
722        let (ahead, behind) = self
723            .repo
724            .graph_ahead_behind(local_commit.id(), upstream_commit.id())
725            .map_err(CascadeError::Git)?;
726
727        Ok((ahead, behind))
728    }
729
730    /// Set upstream tracking for a branch
731    pub fn set_upstream(&self, branch_name: &str, remote: &str, remote_branch: &str) -> Result<()> {
732        let mut config = self.repo.config().map_err(CascadeError::Git)?;
733
734        // Set branch.{branch_name}.remote = remote
735        let remote_key = format!("branch.{branch_name}.remote");
736        config
737            .set_str(&remote_key, remote)
738            .map_err(CascadeError::Git)?;
739
740        // Set branch.{branch_name}.merge = refs/heads/{remote_branch}
741        let merge_key = format!("branch.{branch_name}.merge");
742        let merge_value = format!("refs/heads/{remote_branch}");
743        config
744            .set_str(&merge_key, &merge_value)
745            .map_err(CascadeError::Git)?;
746
747        Ok(())
748    }
749
750    /// Create a commit with all staged changes
751    pub fn commit(&self, message: &str) -> Result<String> {
752        // Validate git user configuration before attempting commit operations
753        self.validate_git_user_config()?;
754
755        let signature = self.get_signature()?;
756        let tree_id = self.get_index_tree()?;
757        let tree = self.repo.find_tree(tree_id).map_err(CascadeError::Git)?;
758
759        // Get parent commits
760        let head = self.repo.head().map_err(CascadeError::Git)?;
761        let parent_commit = head.peel_to_commit().map_err(CascadeError::Git)?;
762
763        let commit_id = self
764            .repo
765            .commit(
766                Some("HEAD"),
767                &signature,
768                &signature,
769                message,
770                &tree,
771                &[&parent_commit],
772            )
773            .map_err(CascadeError::Git)?;
774
775        Output::success(format!("Created commit: {commit_id} - {message}"));
776        Ok(commit_id.to_string())
777    }
778
779    /// Commit any staged changes with a default message
780    pub fn commit_staged_changes(&self, default_message: &str) -> Result<Option<String>> {
781        // Check if there are staged changes
782        let staged_files = self.get_staged_files()?;
783        if staged_files.is_empty() {
784            tracing::debug!("No staged changes to commit");
785            return Ok(None);
786        }
787
788        tracing::debug!("Committing {} staged files", staged_files.len());
789        let commit_hash = self.commit(default_message)?;
790        Ok(Some(commit_hash))
791    }
792
793    /// Stage all changes
794    pub fn stage_all(&self) -> Result<()> {
795        let mut index = self.repo.index().map_err(CascadeError::Git)?;
796
797        index
798            .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
799            .map_err(CascadeError::Git)?;
800
801        index.write().map_err(CascadeError::Git)?;
802        drop(index); // Explicitly close index after staging
803
804        tracing::debug!("Staged all changes");
805        Ok(())
806    }
807
808    /// Ensure the Git index is fully written and closed before external git CLI operations
809    /// This prevents "index is locked" errors when mixing libgit2 and git CLI commands
810    fn ensure_index_closed(&self) -> Result<()> {
811        // Open and immediately close the index to ensure any pending writes are flushed
812        // and file handles are released before we spawn git CLI processes
813        let mut index = self.repo.index().map_err(CascadeError::Git)?;
814        index.write().map_err(CascadeError::Git)?;
815        drop(index); // Explicit drop to release file handle
816
817        // Give the OS a moment to release file handles
818        // This is necessary because even after Rust drops the index,
819        // the OS might not immediately release the lock
820        std::thread::sleep(std::time::Duration::from_millis(10));
821
822        Ok(())
823    }
824
825    /// Stage only specific files (safer than stage_all during rebase)
826    pub fn stage_files(&self, file_paths: &[&str]) -> Result<()> {
827        if file_paths.is_empty() {
828            tracing::debug!("No files to stage");
829            return Ok(());
830        }
831
832        let mut index = self.repo.index().map_err(CascadeError::Git)?;
833
834        for file_path in file_paths {
835            index
836                .add_path(std::path::Path::new(file_path))
837                .map_err(CascadeError::Git)?;
838        }
839
840        index.write().map_err(CascadeError::Git)?;
841        drop(index); // Explicitly close index after staging
842
843        tracing::debug!(
844            "Staged {} specific files: {:?}",
845            file_paths.len(),
846            file_paths
847        );
848        Ok(())
849    }
850
851    /// Stage only files that had conflicts (safer for rebase operations)
852    pub fn stage_conflict_resolved_files(&self) -> Result<()> {
853        let conflicted_files = self.get_conflicted_files()?;
854        if conflicted_files.is_empty() {
855            tracing::debug!("No conflicted files to stage");
856            return Ok(());
857        }
858
859        let file_paths: Vec<&str> = conflicted_files.iter().map(|s| s.as_str()).collect();
860        self.stage_files(&file_paths)?;
861
862        tracing::debug!("Staged {} conflict-resolved files", conflicted_files.len());
863        Ok(())
864    }
865
866    /// Clean up any in-progress merge/revert/cherry-pick state (removes CHERRY_PICK_HEAD etc.)
867    pub fn cleanup_state(&self) -> Result<()> {
868        let state = self.repo.state();
869        if state == git2::RepositoryState::Clean {
870            return Ok(());
871        }
872
873        tracing::debug!("Cleaning up repository state: {:?}", state);
874        self.repo.cleanup_state().map_err(|e| {
875            CascadeError::branch(format!(
876                "Failed to clean up repository state ({:?}): {}",
877                state, e
878            ))
879        })
880    }
881
882    /// Get repository path
883    pub fn path(&self) -> &Path {
884        &self.path
885    }
886
887    /// Check if a commit exists
888    pub fn commit_exists(&self, commit_hash: &str) -> Result<bool> {
889        match Oid::from_str(commit_hash) {
890            Ok(oid) => match self.repo.find_commit(oid) {
891                Ok(_) => Ok(true),
892                Err(_) => Ok(false),
893            },
894            Err(_) => Ok(false),
895        }
896    }
897
898    /// Check if a commit is already correctly based on a given parent
899    /// Returns true if the commit's parent matches the expected base
900    pub fn is_commit_based_on(&self, commit_hash: &str, expected_base: &str) -> Result<bool> {
901        let commit_oid = Oid::from_str(commit_hash).map_err(|e| {
902            CascadeError::branch(format!("Invalid commit hash '{}': {}", commit_hash, e))
903        })?;
904
905        let commit = self.repo.find_commit(commit_oid).map_err(|e| {
906            CascadeError::branch(format!("Commit '{}' not found: {}", commit_hash, e))
907        })?;
908
909        // Get the commit's parent (first parent for merge commits)
910        if commit.parent_count() == 0 {
911            // Root commit has no parent
912            return Ok(false);
913        }
914
915        let parent = commit.parent(0).map_err(|e| {
916            CascadeError::branch(format!(
917                "Could not get parent of commit '{}': {}",
918                commit_hash, e
919            ))
920        })?;
921        let parent_hash = parent.id().to_string();
922
923        // Check if expected_base is a commit hash or a branch name
924        let expected_base_oid = if let Ok(oid) = Oid::from_str(expected_base) {
925            oid
926        } else {
927            // Try to resolve as a branch name
928            let branch_ref = format!("refs/heads/{}", expected_base);
929            let reference = self.repo.find_reference(&branch_ref).map_err(|e| {
930                CascadeError::branch(format!("Could not find base '{}': {}", expected_base, e))
931            })?;
932            reference.target().ok_or_else(|| {
933                CascadeError::branch(format!("Base '{}' has no target commit", expected_base))
934            })?
935        };
936
937        let expected_base_hash = expected_base_oid.to_string();
938
939        tracing::debug!(
940            "Checking if commit {} is based on {}: parent={}, expected={}",
941            &commit_hash[..8],
942            expected_base,
943            &parent_hash[..8],
944            &expected_base_hash[..8]
945        );
946
947        Ok(parent_hash == expected_base_hash)
948    }
949
950    /// Check whether `descendant` commit contains `ancestor` in its history
951    pub fn is_descendant_of(&self, descendant: &str, ancestor: &str) -> Result<bool> {
952        let descendant_oid = Oid::from_str(descendant).map_err(|e| {
953            CascadeError::branch(format!(
954                "Invalid commit hash '{}' for descendant check: {}",
955                descendant, e
956            ))
957        })?;
958        let ancestor_oid = Oid::from_str(ancestor).map_err(|e| {
959            CascadeError::branch(format!(
960                "Invalid commit hash '{}' for descendant check: {}",
961                ancestor, e
962            ))
963        })?;
964
965        self.repo
966            .graph_descendant_of(descendant_oid, ancestor_oid)
967            .map_err(CascadeError::Git)
968    }
969
970    /// Get the HEAD commit object
971    pub fn get_head_commit(&self) -> Result<git2::Commit<'_>> {
972        let head = self
973            .repo
974            .head()
975            .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
976        head.peel_to_commit()
977            .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))
978    }
979
980    /// Get a commit object by hash
981    pub fn get_commit(&self, commit_hash: &str) -> Result<git2::Commit<'_>> {
982        let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
983
984        self.repo.find_commit(oid).map_err(CascadeError::Git)
985    }
986
987    /// Get the commit hash at the head of a branch
988    pub fn get_branch_head(&self, branch_name: &str) -> Result<String> {
989        let branch = self
990            .repo
991            .find_branch(branch_name, git2::BranchType::Local)
992            .map_err(|e| {
993                CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
994            })?;
995
996        let commit = branch.get().peel_to_commit().map_err(|e| {
997            CascadeError::branch(format!(
998                "Could not get commit for branch '{branch_name}': {e}"
999            ))
1000        })?;
1001
1002        Ok(commit.id().to_string())
1003    }
1004
1005    /// Get the commit hash at the head of a remote branch
1006    pub fn get_remote_branch_head(&self, branch_name: &str) -> Result<String> {
1007        let refname = format!("refs/remotes/origin/{branch_name}");
1008        let reference = self.repo.find_reference(&refname).map_err(|e| {
1009            CascadeError::branch(format!("Remote branch '{branch_name}' not found: {e}"))
1010        })?;
1011
1012        let target = reference.target().ok_or_else(|| {
1013            CascadeError::branch(format!(
1014                "Remote branch '{branch_name}' does not have a target commit"
1015            ))
1016        })?;
1017
1018        Ok(target.to_string())
1019    }
1020
1021    /// Validate git user configuration is properly set
1022    pub fn validate_git_user_config(&self) -> Result<()> {
1023        if let Ok(config) = self.repo.config() {
1024            let name_result = config.get_string("user.name");
1025            let email_result = config.get_string("user.email");
1026
1027            if let (Ok(name), Ok(email)) = (name_result, email_result) {
1028                if !name.trim().is_empty() && !email.trim().is_empty() {
1029                    tracing::debug!("Git user config validated: {} <{}>", name, email);
1030                    return Ok(());
1031                }
1032            }
1033        }
1034
1035        // Check if this is a CI environment where validation can be skipped
1036        let is_ci = std::env::var("CI").is_ok();
1037
1038        if is_ci {
1039            tracing::debug!("CI environment - skipping git user config validation");
1040            return Ok(());
1041        }
1042
1043        Output::warning("Git user configuration missing or incomplete");
1044        Output::info("This can cause cherry-pick and commit operations to fail");
1045        Output::info("Please configure git user information:");
1046        Output::bullet("git config user.name \"Your Name\"".to_string());
1047        Output::bullet("git config user.email \"your.email@example.com\"".to_string());
1048        Output::info("Or set globally with the --global flag");
1049
1050        // Don't fail - let operations continue with fallback signature
1051        // This preserves backward compatibility while providing guidance
1052        Ok(())
1053    }
1054
1055    /// Get a signature for commits with comprehensive fallback and validation
1056    fn get_signature(&self) -> Result<Signature<'_>> {
1057        // Try to get signature from Git config first
1058        if let Ok(config) = self.repo.config() {
1059            // Try global/system config first
1060            let name_result = config.get_string("user.name");
1061            let email_result = config.get_string("user.email");
1062
1063            if let (Ok(name), Ok(email)) = (name_result, email_result) {
1064                if !name.trim().is_empty() && !email.trim().is_empty() {
1065                    tracing::debug!("Using git config: {} <{}>", name, email);
1066                    return Signature::now(&name, &email).map_err(CascadeError::Git);
1067                }
1068            } else {
1069                tracing::debug!("Git user config incomplete or missing");
1070            }
1071        }
1072
1073        // Check if this is a CI environment where fallback is acceptable
1074        let is_ci = std::env::var("CI").is_ok();
1075
1076        if is_ci {
1077            tracing::debug!("CI environment detected, using fallback signature");
1078            return Signature::now("Cascade CLI", "cascade@example.com").map_err(CascadeError::Git);
1079        }
1080
1081        // Interactive environment - provide helpful guidance
1082        tracing::warn!("Git user configuration missing - this can cause commit operations to fail");
1083
1084        // Try fallback signature, but warn about the issue
1085        match Signature::now("Cascade CLI", "cascade@example.com") {
1086            Ok(sig) => {
1087                Output::warning("Git user not configured - using fallback signature");
1088                Output::info("For better git history, run:");
1089                Output::bullet("git config user.name \"Your Name\"".to_string());
1090                Output::bullet("git config user.email \"your.email@example.com\"".to_string());
1091                Output::info("Or set it globally with --global flag");
1092                Ok(sig)
1093            }
1094            Err(e) => {
1095                Err(CascadeError::branch(format!(
1096                    "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\""
1097                )))
1098            }
1099        }
1100    }
1101
1102    /// Configure remote callbacks with SSL settings
1103    /// Priority: Cascade SSL config > Git config > Default
1104    fn configure_remote_callbacks(&self) -> Result<git2::RemoteCallbacks<'_>> {
1105        self.configure_remote_callbacks_with_fallback(false)
1106    }
1107
1108    /// Determine if we should retry with DefaultCredentials based on git2 error classification
1109    fn should_retry_with_default_credentials(&self, error: &git2::Error) -> bool {
1110        match error.class() {
1111            // Authentication errors that might be resolved with DefaultCredentials
1112            git2::ErrorClass::Http => {
1113                // HTTP errors often indicate authentication issues in corporate environments
1114                match error.code() {
1115                    git2::ErrorCode::Auth => true,
1116                    _ => {
1117                        // Check for specific HTTP authentication replay errors
1118                        let error_string = error.to_string();
1119                        error_string.contains("too many redirects")
1120                            || error_string.contains("authentication replays")
1121                            || error_string.contains("authentication required")
1122                    }
1123                }
1124            }
1125            git2::ErrorClass::Net => {
1126                // Network errors that might be authentication-related
1127                let error_string = error.to_string();
1128                error_string.contains("authentication")
1129                    || error_string.contains("unauthorized")
1130                    || error_string.contains("forbidden")
1131            }
1132            _ => false,
1133        }
1134    }
1135
1136    /// Determine if we should fallback to git CLI based on git2 error classification
1137    fn should_fallback_to_git_cli(&self, error: &git2::Error) -> bool {
1138        match error.class() {
1139            // SSL/TLS errors that git CLI handles better
1140            git2::ErrorClass::Ssl => true,
1141
1142            // Certificate errors
1143            git2::ErrorClass::Http if error.code() == git2::ErrorCode::Certificate => true,
1144
1145            // SSH errors that might need git CLI
1146            git2::ErrorClass::Ssh => {
1147                let error_string = error.to_string();
1148                error_string.contains("no callback set")
1149                    || error_string.contains("authentication required")
1150            }
1151
1152            // Network errors that might be proxy/firewall related
1153            git2::ErrorClass::Net => {
1154                let error_string = error.to_string();
1155                error_string.contains("TLS stream")
1156                    || error_string.contains("SSL")
1157                    || error_string.contains("proxy")
1158                    || error_string.contains("firewall")
1159            }
1160
1161            // General HTTP errors not handled by DefaultCredentials retry
1162            git2::ErrorClass::Http => {
1163                let error_string = error.to_string();
1164                error_string.contains("TLS stream")
1165                    || error_string.contains("SSL")
1166                    || error_string.contains("proxy")
1167            }
1168
1169            _ => false,
1170        }
1171    }
1172
1173    fn configure_remote_callbacks_with_fallback(
1174        &self,
1175        use_default_first: bool,
1176    ) -> Result<git2::RemoteCallbacks<'_>> {
1177        let mut callbacks = git2::RemoteCallbacks::new();
1178
1179        // Configure authentication with comprehensive credential support
1180        let bitbucket_credentials = self.bitbucket_credentials.clone();
1181        callbacks.credentials(move |url, username_from_url, allowed_types| {
1182            tracing::debug!(
1183                "Authentication requested for URL: {}, username: {:?}, allowed_types: {:?}",
1184                url,
1185                username_from_url,
1186                allowed_types
1187            );
1188
1189            // For SSH URLs with username
1190            if allowed_types.contains(git2::CredentialType::SSH_KEY) {
1191                if let Some(username) = username_from_url {
1192                    tracing::debug!("Trying SSH key authentication for user: {}", username);
1193                    return git2::Cred::ssh_key_from_agent(username);
1194                }
1195            }
1196
1197            // For HTTPS URLs, try multiple authentication methods in sequence
1198            if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
1199                // If we're in corporate network fallback mode, try DefaultCredentials first
1200                if use_default_first {
1201                    tracing::debug!("Corporate network mode: trying DefaultCredentials first");
1202                    return git2::Cred::default();
1203                }
1204
1205                if url.contains("bitbucket") {
1206                    if let Some(creds) = &bitbucket_credentials {
1207                        // Method 1: Username + Token (common for Bitbucket)
1208                        if let (Some(username), Some(token)) = (&creds.username, &creds.token) {
1209                            tracing::debug!("Trying Bitbucket username + token authentication");
1210                            return git2::Cred::userpass_plaintext(username, token);
1211                        }
1212
1213                        // Method 2: Token as username, empty password (alternate Bitbucket format)
1214                        if let Some(token) = &creds.token {
1215                            tracing::debug!("Trying Bitbucket token-as-username authentication");
1216                            return git2::Cred::userpass_plaintext(token, "");
1217                        }
1218
1219                        // Method 3: Just username (will prompt for password or use credential helper)
1220                        if let Some(username) = &creds.username {
1221                            tracing::debug!("Trying Bitbucket username authentication (will use credential helper)");
1222                            return git2::Cred::username(username);
1223                        }
1224                    }
1225                }
1226
1227                // Method 4: Default credential helper for all HTTPS URLs
1228                tracing::debug!("Trying default credential helper for HTTPS authentication");
1229                return git2::Cred::default();
1230            }
1231
1232            // Fallback to default for any other cases
1233            tracing::debug!("Using default credential fallback");
1234            git2::Cred::default()
1235        });
1236
1237        // Configure SSL certificate checking with system certificates by default
1238        // This matches what tools like Graphite, Sapling, and Phabricator do
1239        // Priority: 1. Use system certificates (default), 2. Manual overrides only if needed
1240
1241        let mut ssl_configured = false;
1242
1243        // Check for manual SSL overrides first (only when user explicitly needs them)
1244        if let Some(ssl_config) = &self.ssl_config {
1245            if ssl_config.accept_invalid_certs {
1246                Output::warning(
1247                    "SSL certificate verification DISABLED via Cascade config - this is insecure!",
1248                );
1249                callbacks.certificate_check(|_cert, _host| {
1250                    tracing::debug!("⚠️  Accepting invalid certificate for host: {}", _host);
1251                    Ok(git2::CertificateCheckStatus::CertificateOk)
1252                });
1253                ssl_configured = true;
1254            } else if let Some(ca_path) = &ssl_config.ca_bundle_path {
1255                Output::info(format!(
1256                    "Using custom CA bundle from Cascade config: {ca_path}"
1257                ));
1258                callbacks.certificate_check(|_cert, host| {
1259                    tracing::debug!("Using custom CA bundle for host: {}", host);
1260                    Ok(git2::CertificateCheckStatus::CertificateOk)
1261                });
1262                ssl_configured = true;
1263            }
1264        }
1265
1266        // Check git config for manual overrides
1267        if !ssl_configured {
1268            if let Ok(config) = self.repo.config() {
1269                let ssl_verify = config.get_bool("http.sslVerify").unwrap_or(true);
1270
1271                if !ssl_verify {
1272                    Output::warning(
1273                        "SSL certificate verification DISABLED via git config - this is insecure!",
1274                    );
1275                    callbacks.certificate_check(|_cert, host| {
1276                        tracing::debug!("⚠️  Bypassing SSL verification for host: {}", host);
1277                        Ok(git2::CertificateCheckStatus::CertificateOk)
1278                    });
1279                    ssl_configured = true;
1280                } else if let Ok(ca_path) = config.get_string("http.sslCAInfo") {
1281                    Output::info(format!("Using custom CA bundle from git config: {ca_path}"));
1282                    callbacks.certificate_check(|_cert, host| {
1283                        tracing::debug!("Using git config CA bundle for host: {}", host);
1284                        Ok(git2::CertificateCheckStatus::CertificateOk)
1285                    });
1286                    ssl_configured = true;
1287                }
1288            }
1289        }
1290
1291        // DEFAULT BEHAVIOR: Use system certificates (like git CLI and other modern tools)
1292        // This should work out-of-the-box in corporate environments
1293        if !ssl_configured {
1294            tracing::debug!(
1295                "Using system certificate store for SSL verification (default behavior)"
1296            );
1297
1298            // For macOS with SecureTransport backend, try default certificate validation first
1299            if cfg!(target_os = "macos") {
1300                tracing::debug!("macOS detected - using default certificate validation");
1301                // Don't set any certificate callback - let git2 use its default behavior
1302                // This often works better with SecureTransport backend on macOS
1303            } else {
1304                // Use CertificatePassthrough for other platforms
1305                callbacks.certificate_check(|_cert, host| {
1306                    tracing::debug!("System certificate validation for host: {}", host);
1307                    Ok(git2::CertificateCheckStatus::CertificatePassthrough)
1308                });
1309            }
1310        }
1311
1312        Ok(callbacks)
1313    }
1314
1315    /// Get the tree ID from the current index
1316    fn get_index_tree(&self) -> Result<Oid> {
1317        let mut index = self.repo.index().map_err(CascadeError::Git)?;
1318
1319        index.write_tree().map_err(CascadeError::Git)
1320    }
1321
1322    /// Get repository status
1323    pub fn get_status(&self) -> Result<git2::Statuses<'_>> {
1324        self.repo.statuses(None).map_err(CascadeError::Git)
1325    }
1326
1327    /// Get a summary of repository status
1328    pub fn get_status_summary(&self) -> Result<GitStatusSummary> {
1329        let statuses = self.get_status()?;
1330
1331        let mut staged_files = 0;
1332        let mut unstaged_files = 0;
1333        let mut untracked_files = 0;
1334
1335        for status in statuses.iter() {
1336            let flags = status.status();
1337
1338            if flags.intersects(
1339                git2::Status::INDEX_MODIFIED
1340                    | git2::Status::INDEX_NEW
1341                    | git2::Status::INDEX_DELETED
1342                    | git2::Status::INDEX_RENAMED
1343                    | git2::Status::INDEX_TYPECHANGE,
1344            ) {
1345                staged_files += 1;
1346            }
1347
1348            if flags.intersects(
1349                git2::Status::WT_MODIFIED
1350                    | git2::Status::WT_DELETED
1351                    | git2::Status::WT_TYPECHANGE
1352                    | git2::Status::WT_RENAMED,
1353            ) {
1354                unstaged_files += 1;
1355            }
1356
1357            if flags.intersects(git2::Status::WT_NEW) {
1358                untracked_files += 1;
1359            }
1360        }
1361
1362        Ok(GitStatusSummary {
1363            staged_files,
1364            unstaged_files,
1365            untracked_files,
1366        })
1367    }
1368
1369    /// Get the current commit hash (alias for get_head_commit_hash)
1370    pub fn get_current_commit_hash(&self) -> Result<String> {
1371        self.get_head_commit_hash()
1372    }
1373
1374    /// Get the count of commits between two commits
1375    pub fn get_commit_count_between(&self, from_commit: &str, to_commit: &str) -> Result<usize> {
1376        let from_oid = git2::Oid::from_str(from_commit).map_err(CascadeError::Git)?;
1377        let to_oid = git2::Oid::from_str(to_commit).map_err(CascadeError::Git)?;
1378
1379        let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
1380        revwalk.push(to_oid).map_err(CascadeError::Git)?;
1381        revwalk.hide(from_oid).map_err(CascadeError::Git)?;
1382
1383        Ok(revwalk.count())
1384    }
1385
1386    /// Get remote URL for a given remote name
1387    pub fn get_remote_url(&self, name: &str) -> Result<String> {
1388        let remote = self.repo.find_remote(name).map_err(CascadeError::Git)?;
1389        Ok(remote.url().unwrap_or("unknown").to_string())
1390    }
1391
1392    /// Cherry-pick a specific commit to the current branch
1393    pub fn cherry_pick(&self, commit_hash: &str) -> Result<String> {
1394        tracing::debug!("Cherry-picking commit {}", commit_hash);
1395
1396        // Validate git user configuration before attempting commit operations
1397        self.validate_git_user_config()?;
1398
1399        let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
1400        let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
1401
1402        // Get the commit's tree
1403        let commit_tree = commit.tree().map_err(CascadeError::Git)?;
1404
1405        // Get parent tree for merge base
1406        let parent_commit = if commit.parent_count() > 0 {
1407            commit.parent(0).map_err(CascadeError::Git)?
1408        } else {
1409            // Root commit - use empty tree
1410            let empty_tree_oid = self.repo.treebuilder(None)?.write()?;
1411            let empty_tree = self.repo.find_tree(empty_tree_oid)?;
1412            let sig = self.get_signature()?;
1413            return self
1414                .repo
1415                .commit(
1416                    Some("HEAD"),
1417                    &sig,
1418                    &sig,
1419                    commit.message().unwrap_or("Cherry-picked commit"),
1420                    &empty_tree,
1421                    &[],
1422                )
1423                .map(|oid| oid.to_string())
1424                .map_err(CascadeError::Git);
1425        };
1426
1427        let parent_tree = parent_commit.tree().map_err(CascadeError::Git)?;
1428
1429        // Get current HEAD tree for 3-way merge
1430        let head_commit = self.get_head_commit()?;
1431        let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
1432
1433        // Perform 3-way merge
1434        let mut index = self
1435            .repo
1436            .merge_trees(&parent_tree, &head_tree, &commit_tree, None)
1437            .map_err(CascadeError::Git)?;
1438
1439        // Check for conflicts
1440        if index.has_conflicts() {
1441            // CRITICAL: Write the conflicted state to disk so auto-resolve can see it!
1442            // Without this, conflicts only exist in memory and Git's index stays clean
1443            debug!("Cherry-pick has conflicts - writing conflicted state to disk for resolution");
1444
1445            // The merge_trees() index is in-memory only. We need to:
1446            // 1. Get the repository's actual index
1447            // 2. Read entries from the merge result into it
1448            // 3. Write the repository index to disk
1449
1450            let mut repo_index = self.repo.index().map_err(CascadeError::Git)?;
1451
1452            // Clear the current index and read from the merge result
1453            repo_index.clear().map_err(CascadeError::Git)?;
1454            repo_index
1455                .read_tree(&head_tree)
1456                .map_err(CascadeError::Git)?;
1457
1458            // Now merge the commit tree into the repo index (this will create conflicts)
1459            repo_index
1460                .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
1461                .map_err(CascadeError::Git)?;
1462
1463            // Use git CLI to do the actual cherry-pick with conflicts
1464            // This is more reliable than trying to manually construct the conflicted index
1465
1466            // First, ensure our libgit2 index is closed so git CLI can work
1467            drop(repo_index);
1468            self.ensure_index_closed()?;
1469
1470            let cherry_pick_output = std::process::Command::new("git")
1471                .args(["cherry-pick", commit_hash])
1472                .current_dir(self.path())
1473                .output()
1474                .map_err(CascadeError::Io)?;
1475
1476            if !cherry_pick_output.status.success() {
1477                debug!("Git CLI cherry-pick failed as expected (has conflicts)");
1478                // This is expected - the cherry-pick failed due to conflicts
1479                // The conflicts are now in the working directory and index
1480            }
1481
1482            // CRITICAL: Reload the index from disk so libgit2 sees the conflicts
1483            // Git CLI wrote the conflicts to disk, but our in-memory index doesn't know yet
1484            self.repo
1485                .index()
1486                .and_then(|mut idx| idx.read(true).map(|_| ()))
1487                .map_err(CascadeError::Git)?;
1488
1489            debug!("Conflicted state written and index reloaded - auto-resolve can now process conflicts");
1490
1491            return Err(CascadeError::branch(format!(
1492                "Cherry-pick of {commit_hash} has conflicts that need manual resolution"
1493            )));
1494        }
1495
1496        // Write merged tree
1497        let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
1498        let merged_tree = self
1499            .repo
1500            .find_tree(merged_tree_oid)
1501            .map_err(CascadeError::Git)?;
1502
1503        // Create new commit with original message (preserve it exactly)
1504        let signature = self.get_signature()?;
1505        let message = commit.message().unwrap_or("Cherry-picked commit");
1506
1507        let new_commit_oid = self
1508            .repo
1509            .commit(
1510                Some("HEAD"),
1511                &signature,
1512                &signature,
1513                message,
1514                &merged_tree,
1515                &[&head_commit],
1516            )
1517            .map_err(CascadeError::Git)?;
1518
1519        // Update working directory to reflect the new commit
1520        let new_commit = self
1521            .repo
1522            .find_commit(new_commit_oid)
1523            .map_err(CascadeError::Git)?;
1524        let new_tree = new_commit.tree().map_err(CascadeError::Git)?;
1525
1526        self.repo
1527            .checkout_tree(
1528                new_tree.as_object(),
1529                Some(git2::build::CheckoutBuilder::new().force()),
1530            )
1531            .map_err(CascadeError::Git)?;
1532
1533        tracing::debug!("Cherry-picked {} -> {}", commit_hash, new_commit_oid);
1534        Ok(new_commit_oid.to_string())
1535    }
1536
1537    /// Check for merge conflicts in the index
1538    pub fn has_conflicts(&self) -> Result<bool> {
1539        let index = self.repo.index().map_err(CascadeError::Git)?;
1540        Ok(index.has_conflicts())
1541    }
1542
1543    /// Get list of conflicted files
1544    pub fn get_conflicted_files(&self) -> Result<Vec<String>> {
1545        let index = self.repo.index().map_err(CascadeError::Git)?;
1546
1547        let mut conflicts = Vec::new();
1548
1549        // Iterate through index conflicts
1550        let conflict_iter = index.conflicts().map_err(CascadeError::Git)?;
1551
1552        for conflict in conflict_iter {
1553            let conflict = conflict.map_err(CascadeError::Git)?;
1554            if let Some(our) = conflict.our {
1555                if let Ok(path) = std::str::from_utf8(&our.path) {
1556                    conflicts.push(path.to_string());
1557                }
1558            } else if let Some(their) = conflict.their {
1559                if let Ok(path) = std::str::from_utf8(&their.path) {
1560                    conflicts.push(path.to_string());
1561                }
1562            }
1563        }
1564
1565        Ok(conflicts)
1566    }
1567
1568    /// Fetch from remote origin
1569    pub fn fetch(&self) -> Result<()> {
1570        tracing::debug!("Fetching from origin");
1571
1572        // CRITICAL: Ensure index is closed before fetch operation
1573        // This prevents "index is locked" errors when fetch is called after cherry-pick/commit
1574        self.ensure_index_closed()?;
1575
1576        let mut remote = self
1577            .repo
1578            .find_remote("origin")
1579            .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
1580
1581        // Configure callbacks with SSL settings from git config
1582        let callbacks = self.configure_remote_callbacks()?;
1583
1584        // Fetch options with authentication and SSL config
1585        let mut fetch_options = git2::FetchOptions::new();
1586        fetch_options.remote_callbacks(callbacks);
1587
1588        // Fetch with authentication
1589        match remote.fetch::<&str>(&[], Some(&mut fetch_options), None) {
1590            Ok(_) => {
1591                tracing::debug!("Fetch completed successfully");
1592                Ok(())
1593            }
1594            Err(e) => {
1595                if self.should_retry_with_default_credentials(&e) {
1596                    tracing::debug!(
1597                        "Authentication error detected (class: {:?}, code: {:?}): {}, retrying with DefaultCredentials",
1598                        e.class(), e.code(), e
1599                    );
1600
1601                    // Retry with DefaultCredentials for corporate networks
1602                    let callbacks = self.configure_remote_callbacks_with_fallback(true)?;
1603                    let mut fetch_options = git2::FetchOptions::new();
1604                    fetch_options.remote_callbacks(callbacks);
1605
1606                    match remote.fetch::<&str>(&[], Some(&mut fetch_options), None) {
1607                        Ok(_) => {
1608                            tracing::debug!("Fetch succeeded with DefaultCredentials");
1609                            return Ok(());
1610                        }
1611                        Err(retry_error) => {
1612                            tracing::debug!(
1613                                "DefaultCredentials retry failed: {}, falling back to git CLI",
1614                                retry_error
1615                            );
1616                            return self.fetch_with_git_cli();
1617                        }
1618                    }
1619                }
1620
1621                if self.should_fallback_to_git_cli(&e) {
1622                    tracing::debug!(
1623                        "Network/SSL error detected (class: {:?}, code: {:?}): {}, falling back to git CLI for fetch operation",
1624                        e.class(), e.code(), e
1625                    );
1626                    return self.fetch_with_git_cli();
1627                }
1628                Err(CascadeError::Git(e))
1629            }
1630        }
1631    }
1632
1633    /// Fetch from remote with exponential backoff retry logic
1634    /// This is critical for force push safety checks to prevent data loss from stale refs
1635    pub fn fetch_with_retry(&self) -> Result<()> {
1636        const MAX_RETRIES: u32 = 3;
1637        const BASE_DELAY_MS: u64 = 500;
1638
1639        let mut last_error = None;
1640
1641        for attempt in 0..MAX_RETRIES {
1642            match self.fetch() {
1643                Ok(_) => return Ok(()),
1644                Err(e) => {
1645                    last_error = Some(e);
1646
1647                    if attempt < MAX_RETRIES - 1 {
1648                        let delay_ms = BASE_DELAY_MS * 2_u64.pow(attempt);
1649                        debug!(
1650                            "Fetch attempt {} failed, retrying in {}ms...",
1651                            attempt + 1,
1652                            delay_ms
1653                        );
1654                        std::thread::sleep(std::time::Duration::from_millis(delay_ms));
1655                    }
1656                }
1657            }
1658        }
1659
1660        // All retries failed - this is CRITICAL for force push safety
1661        Err(CascadeError::Git(git2::Error::from_str(&format!(
1662            "Critical: Failed to fetch remote refs after {} attempts. Cannot safely proceed with force push - \
1663             stale remote refs could cause data loss. Error: {}. Please check network connection.",
1664            MAX_RETRIES,
1665            last_error.unwrap()
1666        ))))
1667    }
1668
1669    /// Pull changes from remote (fetch + merge)
1670    pub fn pull(&self, branch: &str) -> Result<()> {
1671        tracing::debug!("Pulling branch: {}", branch);
1672
1673        // First fetch - this now includes TLS fallback
1674        match self.fetch() {
1675            Ok(_) => {}
1676            Err(e) => {
1677                // If fetch failed even with CLI fallback, try full git pull as last resort
1678                let error_string = e.to_string();
1679                if error_string.contains("TLS stream") || error_string.contains("SSL") {
1680                    tracing::warn!(
1681                        "git2 error detected: {}, falling back to git CLI for pull operation",
1682                        e
1683                    );
1684                    return self.pull_with_git_cli(branch);
1685                }
1686                return Err(e);
1687            }
1688        }
1689
1690        // Get remote tracking branch
1691        let remote_branch_name = format!("origin/{branch}");
1692        let remote_oid = self
1693            .repo
1694            .refname_to_id(&format!("refs/remotes/{remote_branch_name}"))
1695            .map_err(|e| {
1696                CascadeError::branch(format!("Remote branch {remote_branch_name} not found: {e}"))
1697            })?;
1698
1699        let remote_commit = self
1700            .repo
1701            .find_commit(remote_oid)
1702            .map_err(CascadeError::Git)?;
1703
1704        // Get current HEAD
1705        let head_commit = self.get_head_commit()?;
1706
1707        // Check if already up to date
1708        if head_commit.id() == remote_commit.id() {
1709            tracing::debug!("Already up to date");
1710            return Ok(());
1711        }
1712
1713        // Check if we can fast-forward (local is ancestor of remote)
1714        let merge_base_oid = self
1715            .repo
1716            .merge_base(head_commit.id(), remote_commit.id())
1717            .map_err(CascadeError::Git)?;
1718
1719        if merge_base_oid == head_commit.id() {
1720            // Fast-forward: local is direct ancestor of remote, just move pointer
1721            tracing::debug!("Fast-forwarding {} to {}", branch, remote_commit.id());
1722
1723            // Update the branch reference to point to remote commit
1724            let refname = format!("refs/heads/{}", branch);
1725            self.repo
1726                .reference(&refname, remote_oid, true, "pull: Fast-forward")
1727                .map_err(CascadeError::Git)?;
1728
1729            // Update HEAD to point to the new commit
1730            self.repo.set_head(&refname).map_err(CascadeError::Git)?;
1731
1732            // Checkout the new commit (update working directory)
1733            self.repo
1734                .checkout_head(Some(
1735                    git2::build::CheckoutBuilder::new()
1736                        .force()
1737                        .remove_untracked(false),
1738                ))
1739                .map_err(CascadeError::Git)?;
1740
1741            tracing::debug!("Fast-forwarded to {}", remote_commit.id());
1742            return Ok(());
1743        }
1744
1745        // If we can't fast-forward, the local branch has diverged
1746        // This should NOT happen on protected branches!
1747        Err(CascadeError::branch(format!(
1748            "Branch '{}' has diverged from remote. Local has commits not in remote. \
1749             Protected branches should not have local commits. \
1750             Try: git reset --hard origin/{}",
1751            branch, branch
1752        )))
1753    }
1754
1755    /// Push current branch to remote
1756    pub fn push(&self, branch: &str) -> Result<()> {
1757        // Pushing branch to remote
1758
1759        let mut remote = self
1760            .repo
1761            .find_remote("origin")
1762            .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
1763
1764        let remote_url = remote.url().unwrap_or("unknown").to_string();
1765        tracing::debug!("Remote URL: {}", remote_url);
1766
1767        let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
1768        tracing::debug!("Push refspec: {}", refspec);
1769
1770        // Configure callbacks with enhanced SSL settings and error handling
1771        let mut callbacks = self.configure_remote_callbacks()?;
1772
1773        // Add enhanced progress and error callbacks for better debugging
1774        callbacks.push_update_reference(|refname, status| {
1775            if let Some(msg) = status {
1776                tracing::debug!("Push failed for ref {}: {}", refname, msg);
1777                return Err(git2::Error::from_str(&format!("Push failed: {msg}")));
1778            }
1779            tracing::debug!("Push succeeded for ref: {}", refname);
1780            Ok(())
1781        });
1782
1783        // Push options with authentication and SSL config
1784        let mut push_options = git2::PushOptions::new();
1785        push_options.remote_callbacks(callbacks);
1786
1787        // Attempt push with enhanced error reporting
1788        match remote.push(&[&refspec], Some(&mut push_options)) {
1789            Ok(_) => {
1790                tracing::debug!("Push completed successfully for branch: {}", branch);
1791                Ok(())
1792            }
1793            Err(e) => {
1794                tracing::debug!(
1795                    "git2 push error: {} (class: {:?}, code: {:?})",
1796                    e,
1797                    e.class(),
1798                    e.code()
1799                );
1800
1801                if self.should_retry_with_default_credentials(&e) {
1802                    tracing::debug!(
1803                        "Authentication error detected (class: {:?}, code: {:?}): {}, retrying with DefaultCredentials",
1804                        e.class(), e.code(), e
1805                    );
1806
1807                    // Retry with DefaultCredentials for corporate networks
1808                    let callbacks = self.configure_remote_callbacks_with_fallback(true)?;
1809                    let mut push_options = git2::PushOptions::new();
1810                    push_options.remote_callbacks(callbacks);
1811
1812                    match remote.push(&[&refspec], Some(&mut push_options)) {
1813                        Ok(_) => {
1814                            tracing::debug!("Push succeeded with DefaultCredentials");
1815                            return Ok(());
1816                        }
1817                        Err(retry_error) => {
1818                            tracing::debug!(
1819                                "DefaultCredentials retry failed: {}, falling back to git CLI",
1820                                retry_error
1821                            );
1822                            return self.push_with_git_cli(branch);
1823                        }
1824                    }
1825                }
1826
1827                if self.should_fallback_to_git_cli(&e) {
1828                    tracing::debug!(
1829                        "Network/SSL error detected (class: {:?}, code: {:?}): {}, falling back to git CLI for push operation",
1830                        e.class(), e.code(), e
1831                    );
1832                    return self.push_with_git_cli(branch);
1833                }
1834
1835                // Create concise error message
1836                let error_msg = if e.to_string().contains("authentication") {
1837                    format!(
1838                        "Authentication failed for branch '{branch}'. Try: git push origin {branch}"
1839                    )
1840                } else {
1841                    format!("Failed to push branch '{branch}': {e}")
1842                };
1843
1844                Err(CascadeError::branch(error_msg))
1845            }
1846        }
1847    }
1848
1849    /// Fallback push method using git CLI instead of git2
1850    /// This is used when git2 has TLS/SSL or auth issues but git CLI works fine
1851    fn push_with_git_cli(&self, branch: &str) -> Result<()> {
1852        // Ensure index is closed before CLI command
1853        self.ensure_index_closed()?;
1854
1855        let output = std::process::Command::new("git")
1856            .args(["push", "origin", branch])
1857            .current_dir(&self.path)
1858            .output()
1859            .map_err(|e| CascadeError::branch(format!("Failed to execute git command: {e}")))?;
1860
1861        if output.status.success() {
1862            // Silent success - no need to log when fallback works
1863            Ok(())
1864        } else {
1865            let stderr = String::from_utf8_lossy(&output.stderr);
1866            let _stdout = String::from_utf8_lossy(&output.stdout);
1867            // Extract the most relevant error message
1868            let error_msg = if stderr.contains("SSL_connect") || stderr.contains("SSL_ERROR") {
1869                "Network error: Unable to connect to repository (VPN may be required)".to_string()
1870            } else if stderr.contains("repository") && stderr.contains("not found") {
1871                "Repository not found - check your Bitbucket configuration".to_string()
1872            } else if stderr.contains("authentication") || stderr.contains("403") {
1873                "Authentication failed - check your credentials".to_string()
1874            } else {
1875                // For other errors, just show the stderr without the verbose prefix
1876                stderr.trim().to_string()
1877            };
1878            Err(CascadeError::branch(error_msg))
1879        }
1880    }
1881
1882    /// Fallback fetch method using git CLI instead of git2
1883    /// This is used when git2 has TLS/SSL issues but git CLI works fine
1884    fn fetch_with_git_cli(&self) -> Result<()> {
1885        tracing::debug!("Using git CLI fallback for fetch operation");
1886
1887        // Ensure index is closed before CLI command
1888        self.ensure_index_closed()?;
1889
1890        let output = std::process::Command::new("git")
1891            .args(["fetch", "origin"])
1892            .current_dir(&self.path)
1893            .output()
1894            .map_err(|e| {
1895                CascadeError::Git(git2::Error::from_str(&format!(
1896                    "Failed to execute git command: {e}"
1897                )))
1898            })?;
1899
1900        if output.status.success() {
1901            tracing::debug!("Git CLI fetch succeeded");
1902            Ok(())
1903        } else {
1904            let stderr = String::from_utf8_lossy(&output.stderr);
1905            let stdout = String::from_utf8_lossy(&output.stdout);
1906            let error_msg = format!(
1907                "Git CLI fetch failed: {}\nStdout: {}\nStderr: {}",
1908                output.status, stdout, stderr
1909            );
1910            Err(CascadeError::Git(git2::Error::from_str(&error_msg)))
1911        }
1912    }
1913
1914    /// Fallback pull method using git CLI instead of git2
1915    /// This is used when git2 has TLS/SSL issues but git CLI works fine
1916    fn pull_with_git_cli(&self, branch: &str) -> Result<()> {
1917        tracing::debug!("Using git CLI fallback for pull operation: {}", branch);
1918
1919        // Ensure index is closed before CLI command
1920        self.ensure_index_closed()?;
1921
1922        let output = std::process::Command::new("git")
1923            .args(["pull", "origin", branch])
1924            .current_dir(&self.path)
1925            .output()
1926            .map_err(|e| {
1927                CascadeError::Git(git2::Error::from_str(&format!(
1928                    "Failed to execute git command: {e}"
1929                )))
1930            })?;
1931
1932        if output.status.success() {
1933            tracing::debug!("Git CLI pull succeeded for branch: {}", branch);
1934            Ok(())
1935        } else {
1936            let stderr = String::from_utf8_lossy(&output.stderr);
1937            let stdout = String::from_utf8_lossy(&output.stdout);
1938            let error_msg = format!(
1939                "Git CLI pull failed for branch '{}': {}\nStdout: {}\nStderr: {}",
1940                branch, output.status, stdout, stderr
1941            );
1942            Err(CascadeError::Git(git2::Error::from_str(&error_msg)))
1943        }
1944    }
1945
1946    /// Fallback force push method using git CLI instead of git2
1947    /// This is used when git2 has TLS/SSL issues but git CLI works fine
1948    fn force_push_with_git_cli(&self, branch: &str) -> Result<()> {
1949        tracing::debug!(
1950            "Using git CLI fallback for force push operation: {}",
1951            branch
1952        );
1953
1954        let output = std::process::Command::new("git")
1955            .args(["push", "--force", "origin", branch])
1956            .current_dir(&self.path)
1957            .output()
1958            .map_err(|e| CascadeError::branch(format!("Failed to execute git command: {e}")))?;
1959
1960        if output.status.success() {
1961            tracing::debug!("Git CLI force push succeeded for branch: {}", branch);
1962            Ok(())
1963        } else {
1964            let stderr = String::from_utf8_lossy(&output.stderr);
1965            let stdout = String::from_utf8_lossy(&output.stdout);
1966            let error_msg = format!(
1967                "Git CLI force push failed for branch '{}': {}\nStdout: {}\nStderr: {}",
1968                branch, output.status, stdout, stderr
1969            );
1970            Err(CascadeError::branch(error_msg))
1971        }
1972    }
1973
1974    /// Delete a local branch
1975    pub fn delete_branch(&self, name: &str) -> Result<()> {
1976        self.delete_branch_with_options(name, false)
1977    }
1978
1979    /// Delete a local branch with force option to bypass safety checks
1980    pub fn delete_branch_unsafe(&self, name: &str) -> Result<()> {
1981        self.delete_branch_with_options(name, true)
1982    }
1983
1984    /// Internal branch deletion implementation with safety options
1985    fn delete_branch_with_options(&self, name: &str, force_unsafe: bool) -> Result<()> {
1986        debug!("Attempting to delete branch: {}", name);
1987
1988        // Enhanced safety check: Detect unpushed commits before deletion
1989        if !force_unsafe {
1990            let safety_result = self.check_branch_deletion_safety(name)?;
1991            if let Some(safety_info) = safety_result {
1992                // Branch has unpushed commits, get user confirmation
1993                self.handle_branch_deletion_confirmation(name, &safety_info)?;
1994            }
1995        }
1996
1997        let mut branch = self
1998            .repo
1999            .find_branch(name, git2::BranchType::Local)
2000            .map_err(|e| CascadeError::branch(format!("Could not find branch '{name}': {e}")))?;
2001
2002        branch
2003            .delete()
2004            .map_err(|e| CascadeError::branch(format!("Could not delete branch '{name}': {e}")))?;
2005
2006        debug!("Successfully deleted branch '{}'", name);
2007        Ok(())
2008    }
2009
2010    /// Get commits between two references
2011    pub fn get_commits_between(&self, from: &str, to: &str) -> Result<Vec<git2::Commit<'_>>> {
2012        let from_oid = self
2013            .repo
2014            .refname_to_id(&format!("refs/heads/{from}"))
2015            .or_else(|_| Oid::from_str(from))
2016            .map_err(|e| CascadeError::branch(format!("Invalid from reference '{from}': {e}")))?;
2017
2018        let to_oid = self
2019            .repo
2020            .refname_to_id(&format!("refs/heads/{to}"))
2021            .or_else(|_| Oid::from_str(to))
2022            .map_err(|e| CascadeError::branch(format!("Invalid to reference '{to}': {e}")))?;
2023
2024        let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
2025
2026        revwalk.push(to_oid).map_err(CascadeError::Git)?;
2027        revwalk.hide(from_oid).map_err(CascadeError::Git)?;
2028
2029        let mut commits = Vec::new();
2030        for oid in revwalk {
2031            let oid = oid.map_err(CascadeError::Git)?;
2032            let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
2033            commits.push(commit);
2034        }
2035
2036        Ok(commits)
2037    }
2038
2039    /// Force push one branch's content to another branch name
2040    /// This is used to preserve PR history while updating branch contents after rebase
2041    pub fn force_push_branch(&self, target_branch: &str, source_branch: &str) -> Result<()> {
2042        self.force_push_branch_with_options(target_branch, source_branch, false)
2043    }
2044
2045    /// Force push with explicit force flag to bypass safety checks
2046    pub fn force_push_branch_unsafe(&self, target_branch: &str, source_branch: &str) -> Result<()> {
2047        self.force_push_branch_with_options(target_branch, source_branch, true)
2048    }
2049
2050    /// Internal force push implementation with safety options
2051    fn force_push_branch_with_options(
2052        &self,
2053        target_branch: &str,
2054        source_branch: &str,
2055        force_unsafe: bool,
2056    ) -> Result<()> {
2057        debug!(
2058            "Force pushing {} content to {} to preserve PR history",
2059            source_branch, target_branch
2060        );
2061
2062        // Enhanced safety check: Detect potential data loss and get user confirmation
2063        if !force_unsafe {
2064            let safety_result = self.check_force_push_safety_enhanced(target_branch)?;
2065            if let Some(backup_info) = safety_result {
2066                // Create backup branch before force push
2067                self.create_backup_branch(target_branch, &backup_info.remote_commit_id)?;
2068                debug!("Created backup branch: {}", backup_info.backup_branch_name);
2069            }
2070        }
2071
2072        // First, ensure we have the latest changes for the source branch
2073        let source_ref = self
2074            .repo
2075            .find_reference(&format!("refs/heads/{source_branch}"))
2076            .map_err(|e| {
2077                CascadeError::config(format!("Failed to find source branch {source_branch}: {e}"))
2078            })?;
2079        let _source_commit = source_ref.peel_to_commit().map_err(|e| {
2080            CascadeError::config(format!(
2081                "Failed to get commit for source branch {source_branch}: {e}"
2082            ))
2083        })?;
2084
2085        // Force push to remote without modifying local target branch
2086        let mut remote = self
2087            .repo
2088            .find_remote("origin")
2089            .map_err(|e| CascadeError::config(format!("Failed to find origin remote: {e}")))?;
2090
2091        // Push source branch content to remote target branch
2092        let refspec = format!("+refs/heads/{source_branch}:refs/heads/{target_branch}");
2093
2094        // Configure callbacks with SSL settings from git config
2095        let callbacks = self.configure_remote_callbacks()?;
2096
2097        // Push options for force push with SSL config
2098        let mut push_options = git2::PushOptions::new();
2099        push_options.remote_callbacks(callbacks);
2100
2101        match remote.push(&[&refspec], Some(&mut push_options)) {
2102            Ok(_) => {}
2103            Err(e) => {
2104                if self.should_retry_with_default_credentials(&e) {
2105                    tracing::debug!(
2106                        "Authentication error detected (class: {:?}, code: {:?}): {}, retrying with DefaultCredentials",
2107                        e.class(), e.code(), e
2108                    );
2109
2110                    // Retry with DefaultCredentials for corporate networks
2111                    let callbacks = self.configure_remote_callbacks_with_fallback(true)?;
2112                    let mut push_options = git2::PushOptions::new();
2113                    push_options.remote_callbacks(callbacks);
2114
2115                    match remote.push(&[&refspec], Some(&mut push_options)) {
2116                        Ok(_) => {
2117                            tracing::debug!("Force push succeeded with DefaultCredentials");
2118                            // Success - continue to normal success path
2119                        }
2120                        Err(retry_error) => {
2121                            tracing::debug!(
2122                                "DefaultCredentials retry failed: {}, falling back to git CLI",
2123                                retry_error
2124                            );
2125                            return self.force_push_with_git_cli(target_branch);
2126                        }
2127                    }
2128                } else if self.should_fallback_to_git_cli(&e) {
2129                    tracing::debug!(
2130                        "Network/SSL error detected (class: {:?}, code: {:?}): {}, falling back to git CLI for force push operation",
2131                        e.class(), e.code(), e
2132                    );
2133                    return self.force_push_with_git_cli(target_branch);
2134                } else {
2135                    return Err(CascadeError::config(format!(
2136                        "Failed to force push {target_branch}: {e}"
2137                    )));
2138                }
2139            }
2140        }
2141
2142        tracing::debug!(
2143            "Successfully force pushed {} to preserve PR history",
2144            target_branch
2145        );
2146        Ok(())
2147    }
2148
2149    /// Enhanced safety check for force push operations with user confirmation
2150    /// Returns backup info if data would be lost and user confirms
2151    fn check_force_push_safety_enhanced(
2152        &self,
2153        target_branch: &str,
2154    ) -> Result<Option<ForceBackupInfo>> {
2155        // First fetch latest remote changes to ensure we have up-to-date information
2156        match self.fetch() {
2157            Ok(_) => {}
2158            Err(e) => {
2159                // If fetch fails, warn but don't block the operation
2160                debug!("Could not fetch latest changes for safety check: {}", e);
2161            }
2162        }
2163
2164        // Check if there are commits on the remote that would be lost
2165        let remote_ref = format!("refs/remotes/origin/{target_branch}");
2166        let local_ref = format!("refs/heads/{target_branch}");
2167
2168        // Try to find both local and remote references
2169        let local_commit = match self.repo.find_reference(&local_ref) {
2170            Ok(reference) => reference.peel_to_commit().ok(),
2171            Err(_) => None,
2172        };
2173
2174        let remote_commit = match self.repo.find_reference(&remote_ref) {
2175            Ok(reference) => reference.peel_to_commit().ok(),
2176            Err(_) => None,
2177        };
2178
2179        // If we have both commits, check for divergence
2180        if let (Some(local), Some(remote)) = (local_commit, remote_commit) {
2181            if local.id() != remote.id() {
2182                // Check if the remote has commits that the local doesn't have
2183                let merge_base_oid = self
2184                    .repo
2185                    .merge_base(local.id(), remote.id())
2186                    .map_err(|e| CascadeError::config(format!("Failed to find merge base: {e}")))?;
2187
2188                // If merge base != remote commit, remote has commits that would be lost
2189                if merge_base_oid != remote.id() {
2190                    let commits_to_lose = self.count_commits_between(
2191                        &merge_base_oid.to_string(),
2192                        &remote.id().to_string(),
2193                    )?;
2194
2195                    // Create backup branch name with timestamp
2196                    let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
2197                    let backup_branch_name = format!("{target_branch}_backup_{timestamp}");
2198
2199                    debug!(
2200                        "Force push to '{}' would overwrite {} commits on remote",
2201                        target_branch, commits_to_lose
2202                    );
2203
2204                    // Check if we're in a non-interactive environment (CI/testing)
2205                    if std::env::var("CI").is_ok() || std::env::var("FORCE_PUSH_NO_CONFIRM").is_ok()
2206                    {
2207                        info!(
2208                            "Non-interactive environment detected, proceeding with backup creation"
2209                        );
2210                        return Ok(Some(ForceBackupInfo {
2211                            backup_branch_name,
2212                            remote_commit_id: remote.id().to_string(),
2213                            commits_that_would_be_lost: commits_to_lose,
2214                        }));
2215                    }
2216
2217                    // Interactive confirmation
2218                    println!();
2219                    Output::warning("FORCE PUSH WARNING");
2220                    println!("Force push to '{target_branch}' would overwrite {commits_to_lose} commits on remote:");
2221
2222                    info!(
2223                        "Remote '{}' has {} commit(s) not present locally. Creating backup branch '{}' before force push.",
2224                        target_branch, commits_to_lose, backup_branch_name
2225                    );
2226
2227                    return Ok(Some(ForceBackupInfo {
2228                        backup_branch_name,
2229                        remote_commit_id: remote.id().to_string(),
2230                        commits_that_would_be_lost: commits_to_lose,
2231                    }));
2232                }
2233            }
2234        }
2235
2236        Ok(None)
2237    }
2238
2239    /// Check force push safety without user confirmation (auto-creates backup)
2240    /// Used for automated operations like sync where user already confirmed the operation
2241    ///
2242    /// When skip_fetch=false: Fetches latest remote state before checking (default behavior)
2243    /// When skip_fetch=true: Assumes fetch already done (batch operations)
2244    fn check_force_push_safety_auto_no_fetch(
2245        &self,
2246        target_branch: &str,
2247    ) -> Result<Option<ForceBackupInfo>> {
2248        // Check if there are commits on the remote that would be lost
2249        let remote_ref = format!("refs/remotes/origin/{target_branch}");
2250        let local_ref = format!("refs/heads/{target_branch}");
2251
2252        // Try to find both local and remote references
2253        let local_commit = match self.repo.find_reference(&local_ref) {
2254            Ok(reference) => reference.peel_to_commit().ok(),
2255            Err(_) => None,
2256        };
2257
2258        let remote_commit = match self.repo.find_reference(&remote_ref) {
2259            Ok(reference) => reference.peel_to_commit().ok(),
2260            Err(_) => None,
2261        };
2262
2263        // If we have both commits, check for divergence
2264        if let (Some(local), Some(remote)) = (local_commit, remote_commit) {
2265            if local.id() != remote.id() {
2266                // Check if the remote has commits that the local doesn't have
2267                let merge_base_oid = self
2268                    .repo
2269                    .merge_base(local.id(), remote.id())
2270                    .map_err(|e| CascadeError::config(format!("Failed to find merge base: {e}")))?;
2271
2272                // If merge base != remote commit, remote has commits that would be lost
2273                if merge_base_oid != remote.id() {
2274                    let commits_to_lose = self.count_commits_between(
2275                        &merge_base_oid.to_string(),
2276                        &remote.id().to_string(),
2277                    )?;
2278
2279                    // Create backup branch name with timestamp
2280                    let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
2281                    let backup_branch_name = format!("{target_branch}_backup_{timestamp}");
2282
2283                    tracing::debug!(
2284                        "Auto-creating backup '{}' for force push to '{}' (would overwrite {} commits)",
2285                        backup_branch_name, target_branch, commits_to_lose
2286                    );
2287
2288                    // Automatically create backup without confirmation
2289                    return Ok(Some(ForceBackupInfo {
2290                        backup_branch_name,
2291                        remote_commit_id: remote.id().to_string(),
2292                        commits_that_would_be_lost: commits_to_lose,
2293                    }));
2294                }
2295            }
2296        }
2297
2298        Ok(None)
2299    }
2300
2301    /// Create a backup branch pointing to the remote commit that would be lost
2302    fn create_backup_branch(&self, original_branch: &str, remote_commit_id: &str) -> Result<()> {
2303        let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
2304        let backup_branch_name = format!("{original_branch}_backup_{timestamp}");
2305
2306        // Parse the commit ID
2307        let commit_oid = Oid::from_str(remote_commit_id).map_err(|e| {
2308            CascadeError::config(format!("Invalid commit ID {remote_commit_id}: {e}"))
2309        })?;
2310
2311        // Find the commit
2312        let commit = self.repo.find_commit(commit_oid).map_err(|e| {
2313            CascadeError::config(format!("Failed to find commit {remote_commit_id}: {e}"))
2314        })?;
2315
2316        // Create the backup branch
2317        self.repo
2318            .branch(&backup_branch_name, &commit, false)
2319            .map_err(|e| {
2320                CascadeError::config(format!(
2321                    "Failed to create backup branch {backup_branch_name}: {e}"
2322                ))
2323            })?;
2324
2325        debug!(
2326            "Created backup branch '{}' pointing to {}",
2327            backup_branch_name,
2328            &remote_commit_id[..8]
2329        );
2330        Ok(())
2331    }
2332
2333    /// Check if branch deletion is safe by detecting unpushed commits
2334    /// Returns safety info if there are concerns that need user attention
2335    fn check_branch_deletion_safety(
2336        &self,
2337        branch_name: &str,
2338    ) -> Result<Option<BranchDeletionSafety>> {
2339        // First, try to fetch latest remote changes
2340        match self.fetch() {
2341            Ok(_) => {}
2342            Err(e) => {
2343                warn!(
2344                    "Could not fetch latest changes for branch deletion safety check: {}",
2345                    e
2346                );
2347            }
2348        }
2349
2350        // Find the branch
2351        let branch = self
2352            .repo
2353            .find_branch(branch_name, git2::BranchType::Local)
2354            .map_err(|e| {
2355                CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
2356            })?;
2357
2358        let _branch_commit = branch.get().peel_to_commit().map_err(|e| {
2359            CascadeError::branch(format!(
2360                "Could not get commit for branch '{branch_name}': {e}"
2361            ))
2362        })?;
2363
2364        // Determine the main branch (try common names)
2365        let main_branch_name = self.detect_main_branch()?;
2366
2367        // Check if branch is merged to main
2368        let is_merged_to_main = self.is_branch_merged_to_main(branch_name, &main_branch_name)?;
2369
2370        // Find the upstream/remote tracking branch
2371        let remote_tracking_branch = self.get_remote_tracking_branch(branch_name);
2372
2373        let mut unpushed_commits = Vec::new();
2374
2375        // Check for unpushed commits compared to remote tracking branch
2376        if let Some(ref remote_branch) = remote_tracking_branch {
2377            match self.get_commits_between(remote_branch, branch_name) {
2378                Ok(commits) => {
2379                    unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
2380                }
2381                Err(_) => {
2382                    // If we can't compare with remote, check against main branch
2383                    if !is_merged_to_main {
2384                        if let Ok(commits) =
2385                            self.get_commits_between(&main_branch_name, branch_name)
2386                        {
2387                            unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
2388                        }
2389                    }
2390                }
2391            }
2392        } else if !is_merged_to_main {
2393            // No remote tracking branch, check against main
2394            if let Ok(commits) = self.get_commits_between(&main_branch_name, branch_name) {
2395                unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
2396            }
2397        }
2398
2399        // If there are concerns, return safety info
2400        if !unpushed_commits.is_empty() || (!is_merged_to_main && remote_tracking_branch.is_none())
2401        {
2402            Ok(Some(BranchDeletionSafety {
2403                unpushed_commits,
2404                remote_tracking_branch,
2405                is_merged_to_main,
2406                main_branch_name,
2407            }))
2408        } else {
2409            Ok(None)
2410        }
2411    }
2412
2413    /// Handle user confirmation for branch deletion with safety concerns
2414    fn handle_branch_deletion_confirmation(
2415        &self,
2416        branch_name: &str,
2417        safety_info: &BranchDeletionSafety,
2418    ) -> Result<()> {
2419        // Check if we're in a non-interactive environment
2420        if std::env::var("CI").is_ok() || std::env::var("BRANCH_DELETE_NO_CONFIRM").is_ok() {
2421            return Err(CascadeError::branch(
2422                format!(
2423                    "Branch '{branch_name}' has {} unpushed commits and cannot be deleted in non-interactive mode. Use --force to override.",
2424                    safety_info.unpushed_commits.len()
2425                )
2426            ));
2427        }
2428
2429        // Interactive warning and confirmation
2430        println!();
2431        Output::warning("BRANCH DELETION WARNING");
2432        println!("Branch '{branch_name}' has potential issues:");
2433
2434        if !safety_info.unpushed_commits.is_empty() {
2435            println!(
2436                "\n🔍 Unpushed commits ({} total):",
2437                safety_info.unpushed_commits.len()
2438            );
2439
2440            // Show details of unpushed commits
2441            for (i, commit_id) in safety_info.unpushed_commits.iter().take(5).enumerate() {
2442                if let Ok(oid) = Oid::from_str(commit_id) {
2443                    if let Ok(commit) = self.repo.find_commit(oid) {
2444                        let short_hash = &commit_id[..8];
2445                        let summary = commit.summary().unwrap_or("<no message>");
2446                        println!("  {}. {} - {}", i + 1, short_hash, summary);
2447                    }
2448                }
2449            }
2450
2451            if safety_info.unpushed_commits.len() > 5 {
2452                println!(
2453                    "  ... and {} more commits",
2454                    safety_info.unpushed_commits.len() - 5
2455                );
2456            }
2457        }
2458
2459        if !safety_info.is_merged_to_main {
2460            println!();
2461            crate::cli::output::Output::section("Branch status");
2462            crate::cli::output::Output::bullet(format!(
2463                "Not merged to '{}'",
2464                safety_info.main_branch_name
2465            ));
2466            if let Some(ref remote) = safety_info.remote_tracking_branch {
2467                crate::cli::output::Output::bullet(format!("Remote tracking branch: {remote}"));
2468            } else {
2469                crate::cli::output::Output::bullet("No remote tracking branch");
2470            }
2471        }
2472
2473        println!();
2474        crate::cli::output::Output::section("Safer alternatives");
2475        if !safety_info.unpushed_commits.is_empty() {
2476            if let Some(ref _remote) = safety_info.remote_tracking_branch {
2477                println!("  • Push commits first: git push origin {branch_name}");
2478            } else {
2479                println!("  • Create and push to remote: git push -u origin {branch_name}");
2480            }
2481        }
2482        if !safety_info.is_merged_to_main {
2483            println!(
2484                "  • Merge to {} first: git checkout {} && git merge {branch_name}",
2485                safety_info.main_branch_name, safety_info.main_branch_name
2486            );
2487        }
2488
2489        let confirmed = Confirm::with_theme(&ColorfulTheme::default())
2490            .with_prompt("Do you want to proceed with deleting this branch?")
2491            .default(false)
2492            .interact()
2493            .map_err(|e| CascadeError::branch(format!("Failed to get user confirmation: {e}")))?;
2494
2495        if !confirmed {
2496            return Err(CascadeError::branch(
2497                "Branch deletion cancelled by user. Use --force to bypass this check.".to_string(),
2498            ));
2499        }
2500
2501        Ok(())
2502    }
2503
2504    /// Detect the main branch name (main, master, develop)
2505    pub fn detect_main_branch(&self) -> Result<String> {
2506        let main_candidates = ["main", "master", "develop", "trunk"];
2507
2508        for candidate in &main_candidates {
2509            if self
2510                .repo
2511                .find_branch(candidate, git2::BranchType::Local)
2512                .is_ok()
2513            {
2514                return Ok(candidate.to_string());
2515            }
2516        }
2517
2518        // Fallback to HEAD's target if it's a symbolic reference
2519        if let Ok(head) = self.repo.head() {
2520            if let Some(name) = head.shorthand() {
2521                return Ok(name.to_string());
2522            }
2523        }
2524
2525        // Final fallback
2526        Ok("main".to_string())
2527    }
2528
2529    /// Check if a branch is merged to the main branch
2530    fn is_branch_merged_to_main(&self, branch_name: &str, main_branch: &str) -> Result<bool> {
2531        // Get the commits between main and the branch
2532        match self.get_commits_between(main_branch, branch_name) {
2533            Ok(commits) => Ok(commits.is_empty()),
2534            Err(_) => {
2535                // If we can't determine, assume not merged for safety
2536                Ok(false)
2537            }
2538        }
2539    }
2540
2541    /// Get the remote tracking branch for a local branch
2542    fn get_remote_tracking_branch(&self, branch_name: &str) -> Option<String> {
2543        // Try common remote tracking branch patterns
2544        let remote_candidates = [
2545            format!("origin/{branch_name}"),
2546            format!("remotes/origin/{branch_name}"),
2547        ];
2548
2549        for candidate in &remote_candidates {
2550            if self
2551                .repo
2552                .find_reference(&format!(
2553                    "refs/remotes/{}",
2554                    candidate.replace("remotes/", "")
2555                ))
2556                .is_ok()
2557            {
2558                return Some(candidate.clone());
2559            }
2560        }
2561
2562        None
2563    }
2564
2565    /// Check if checkout operation is safe
2566    fn check_checkout_safety(&self, _target: &str) -> Result<Option<CheckoutSafety>> {
2567        // Check if there are uncommitted changes
2568        let is_dirty = self.is_dirty()?;
2569        if !is_dirty {
2570            // No uncommitted changes, checkout is safe
2571            return Ok(None);
2572        }
2573
2574        // Get current branch for context
2575        let current_branch = self.get_current_branch().ok();
2576
2577        // Get detailed information about uncommitted changes
2578        let modified_files = self.get_modified_files()?;
2579        let staged_files = self.get_staged_files()?;
2580        let untracked_files = self.get_untracked_files()?;
2581
2582        let has_uncommitted_changes = !modified_files.is_empty() || !staged_files.is_empty();
2583
2584        if has_uncommitted_changes || !untracked_files.is_empty() {
2585            return Ok(Some(CheckoutSafety {
2586                has_uncommitted_changes,
2587                modified_files,
2588                staged_files,
2589                untracked_files,
2590                stash_created: None,
2591                current_branch,
2592            }));
2593        }
2594
2595        Ok(None)
2596    }
2597
2598    /// Handle user confirmation for checkout operations with uncommitted changes
2599    fn handle_checkout_confirmation(
2600        &self,
2601        target: &str,
2602        safety_info: &CheckoutSafety,
2603    ) -> Result<()> {
2604        // Check if we're in a non-interactive environment FIRST (before any output)
2605        let is_ci = std::env::var("CI").is_ok();
2606        let no_confirm = std::env::var("CHECKOUT_NO_CONFIRM").is_ok();
2607        let is_non_interactive = is_ci || no_confirm;
2608
2609        if is_non_interactive {
2610            return Err(CascadeError::branch(
2611                format!(
2612                    "Cannot checkout '{target}' with uncommitted changes in non-interactive mode. Commit your changes or use stash first."
2613                )
2614            ));
2615        }
2616
2617        // Interactive warning and confirmation
2618        println!("\nCHECKOUT WARNING");
2619        println!("Attempting to checkout: {}", target);
2620        println!("You have uncommitted changes that could be lost:");
2621
2622        if !safety_info.modified_files.is_empty() {
2623            println!("\nModified files ({}):", safety_info.modified_files.len());
2624            for file in safety_info.modified_files.iter().take(10) {
2625                println!("   - {file}");
2626            }
2627            if safety_info.modified_files.len() > 10 {
2628                println!("   ... and {} more", safety_info.modified_files.len() - 10);
2629            }
2630        }
2631
2632        if !safety_info.staged_files.is_empty() {
2633            println!("\nStaged files ({}):", safety_info.staged_files.len());
2634            for file in safety_info.staged_files.iter().take(10) {
2635                println!("   - {file}");
2636            }
2637            if safety_info.staged_files.len() > 10 {
2638                println!("   ... and {} more", safety_info.staged_files.len() - 10);
2639            }
2640        }
2641
2642        if !safety_info.untracked_files.is_empty() {
2643            println!("\nUntracked files ({}):", safety_info.untracked_files.len());
2644            for file in safety_info.untracked_files.iter().take(5) {
2645                println!("   - {file}");
2646            }
2647            if safety_info.untracked_files.len() > 5 {
2648                println!("   ... and {} more", safety_info.untracked_files.len() - 5);
2649            }
2650        }
2651
2652        println!("\nOptions:");
2653        println!("1. Stash changes and checkout (recommended)");
2654        println!("2. Force checkout (WILL LOSE UNCOMMITTED CHANGES)");
2655        println!("3. Cancel checkout");
2656
2657        // Use proper selection dialog instead of y/n confirmation
2658        let selection = Select::with_theme(&ColorfulTheme::default())
2659            .with_prompt("Choose an action")
2660            .items(&[
2661                "Stash changes and checkout (recommended)",
2662                "Force checkout (WILL LOSE UNCOMMITTED CHANGES)",
2663                "Cancel checkout",
2664            ])
2665            .default(0)
2666            .interact()
2667            .map_err(|e| CascadeError::branch(format!("Could not get user selection: {e}")))?;
2668
2669        match selection {
2670            0 => {
2671                // Option 1: Stash changes and checkout
2672                let stash_message = format!(
2673                    "Auto-stash before checkout to {} at {}",
2674                    target,
2675                    chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
2676                );
2677
2678                match self.create_stash(&stash_message) {
2679                    Ok(stash_id) => {
2680                        crate::cli::output::Output::success(format!(
2681                            "Created stash: {stash_message} ({stash_id})"
2682                        ));
2683                        crate::cli::output::Output::tip("You can restore with: git stash pop");
2684                    }
2685                    Err(e) => {
2686                        crate::cli::output::Output::error(format!("Failed to create stash: {e}"));
2687
2688                        // If stash failed, provide better options
2689                        use dialoguer::Select;
2690                        let stash_failed_options = vec![
2691                            "Commit staged changes and proceed",
2692                            "Force checkout (WILL LOSE CHANGES)",
2693                            "Cancel and handle manually",
2694                        ];
2695
2696                        let stash_selection = Select::with_theme(&ColorfulTheme::default())
2697                            .with_prompt("Stash failed. What would you like to do?")
2698                            .items(&stash_failed_options)
2699                            .default(0)
2700                            .interact()
2701                            .map_err(|e| {
2702                                CascadeError::branch(format!("Could not get user selection: {e}"))
2703                            })?;
2704
2705                        match stash_selection {
2706                            0 => {
2707                                // Try to commit staged changes
2708                                let staged_files = self.get_staged_files()?;
2709                                if !staged_files.is_empty() {
2710                                    println!(
2711                                        "📝 Committing {} staged files...",
2712                                        staged_files.len()
2713                                    );
2714                                    match self
2715                                        .commit_staged_changes("WIP: Auto-commit before checkout")
2716                                    {
2717                                        Ok(Some(commit_hash)) => {
2718                                            crate::cli::output::Output::success(format!(
2719                                                "Committed staged changes as {}",
2720                                                &commit_hash[..8]
2721                                            ));
2722                                            crate::cli::output::Output::tip(
2723                                                "You can undo with: git reset HEAD~1",
2724                                            );
2725                                        }
2726                                        Ok(None) => {
2727                                            crate::cli::output::Output::info(
2728                                                "No staged changes found to commit",
2729                                            );
2730                                        }
2731                                        Err(commit_err) => {
2732                                            println!(
2733                                                "❌ Failed to commit staged changes: {commit_err}"
2734                                            );
2735                                            return Err(CascadeError::branch(
2736                                                "Could not commit staged changes".to_string(),
2737                                            ));
2738                                        }
2739                                    }
2740                                } else {
2741                                    println!("No staged changes to commit");
2742                                }
2743                            }
2744                            1 => {
2745                                // Force checkout anyway
2746                                Output::warning("Proceeding with force checkout - uncommitted changes will be lost!");
2747                            }
2748                            2 => {
2749                                // Cancel
2750                                return Err(CascadeError::branch(
2751                                    "Checkout cancelled. Please handle changes manually and try again.".to_string(),
2752                                ));
2753                            }
2754                            _ => unreachable!(),
2755                        }
2756                    }
2757                }
2758            }
2759            1 => {
2760                // Option 2: Force checkout (lose changes)
2761                Output::warning(
2762                    "Proceeding with force checkout - uncommitted changes will be lost!",
2763                );
2764            }
2765            2 => {
2766                // Option 3: Cancel
2767                return Err(CascadeError::branch(
2768                    "Checkout cancelled by user".to_string(),
2769                ));
2770            }
2771            _ => unreachable!(),
2772        }
2773
2774        Ok(())
2775    }
2776
2777    /// Create a stash with uncommitted changes
2778    fn create_stash(&self, message: &str) -> Result<String> {
2779        use crate::cli::output::Output;
2780
2781        tracing::debug!("Creating stash: {}", message);
2782
2783        // Use git CLI for stashing since git2 stashing is complex and unreliable
2784        let output = std::process::Command::new("git")
2785            .args(["stash", "push", "-m", message])
2786            .current_dir(&self.path)
2787            .output()
2788            .map_err(|e| {
2789                CascadeError::branch(format!("Failed to execute git stash command: {e}"))
2790            })?;
2791
2792        if output.status.success() {
2793            let stdout = String::from_utf8_lossy(&output.stdout);
2794
2795            // Extract stash hash if available (git stash outputs like "Saved working directory and index state WIP on branch: message")
2796            let stash_id = if stdout.contains("Saved working directory") {
2797                // Get the most recent stash ID
2798                let stash_list_output = std::process::Command::new("git")
2799                    .args(["stash", "list", "-n", "1", "--format=%H"])
2800                    .current_dir(&self.path)
2801                    .output()
2802                    .map_err(|e| CascadeError::branch(format!("Failed to get stash ID: {e}")))?;
2803
2804                if stash_list_output.status.success() {
2805                    String::from_utf8_lossy(&stash_list_output.stdout)
2806                        .trim()
2807                        .to_string()
2808                } else {
2809                    "stash@{0}".to_string() // fallback
2810                }
2811            } else {
2812                "stash@{0}".to_string() // fallback
2813            };
2814
2815            Output::success(format!("Created stash: {} ({})", message, stash_id));
2816            Output::tip("You can restore with: git stash pop");
2817            Ok(stash_id)
2818        } else {
2819            let stderr = String::from_utf8_lossy(&output.stderr);
2820            let stdout = String::from_utf8_lossy(&output.stdout);
2821
2822            // Check for common stash failure reasons
2823            if stderr.contains("No local changes to save")
2824                || stdout.contains("No local changes to save")
2825            {
2826                return Err(CascadeError::branch("No local changes to save".to_string()));
2827            }
2828
2829            Err(CascadeError::branch(format!(
2830                "Failed to create stash: {}\nStderr: {}\nStdout: {}",
2831                output.status, stderr, stdout
2832            )))
2833        }
2834    }
2835
2836    /// Get modified files in working directory
2837    fn get_modified_files(&self) -> Result<Vec<String>> {
2838        let mut opts = git2::StatusOptions::new();
2839        opts.include_untracked(false).include_ignored(false);
2840
2841        let statuses = self
2842            .repo
2843            .statuses(Some(&mut opts))
2844            .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
2845
2846        let mut modified_files = Vec::new();
2847        for status in statuses.iter() {
2848            let flags = status.status();
2849            if flags.contains(git2::Status::WT_MODIFIED) || flags.contains(git2::Status::WT_DELETED)
2850            {
2851                if let Some(path) = status.path() {
2852                    modified_files.push(path.to_string());
2853                }
2854            }
2855        }
2856
2857        Ok(modified_files)
2858    }
2859
2860    /// Get staged files in index
2861    pub fn get_staged_files(&self) -> Result<Vec<String>> {
2862        let mut opts = git2::StatusOptions::new();
2863        opts.include_untracked(false).include_ignored(false);
2864
2865        let statuses = self
2866            .repo
2867            .statuses(Some(&mut opts))
2868            .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
2869
2870        let mut staged_files = Vec::new();
2871        for status in statuses.iter() {
2872            let flags = status.status();
2873            if flags.contains(git2::Status::INDEX_MODIFIED)
2874                || flags.contains(git2::Status::INDEX_NEW)
2875                || flags.contains(git2::Status::INDEX_DELETED)
2876            {
2877                if let Some(path) = status.path() {
2878                    staged_files.push(path.to_string());
2879                }
2880            }
2881        }
2882
2883        Ok(staged_files)
2884    }
2885
2886    /// Count commits between two references
2887    fn count_commits_between(&self, from: &str, to: &str) -> Result<usize> {
2888        let commits = self.get_commits_between(from, to)?;
2889        Ok(commits.len())
2890    }
2891
2892    /// Resolve a reference (branch name, tag, or commit hash) to a commit
2893    pub fn resolve_reference(&self, reference: &str) -> Result<git2::Commit<'_>> {
2894        // Try to parse as commit hash first
2895        if let Ok(oid) = Oid::from_str(reference) {
2896            if let Ok(commit) = self.repo.find_commit(oid) {
2897                return Ok(commit);
2898            }
2899        }
2900
2901        // Try to resolve as a reference (branch, tag, etc.)
2902        let obj = self.repo.revparse_single(reference).map_err(|e| {
2903            CascadeError::branch(format!("Could not resolve reference '{reference}': {e}"))
2904        })?;
2905
2906        obj.peel_to_commit().map_err(|e| {
2907            CascadeError::branch(format!(
2908                "Reference '{reference}' does not point to a commit: {e}"
2909            ))
2910        })
2911    }
2912
2913    /// Reset HEAD to a specific reference (soft reset)
2914    pub fn reset_soft(&self, target_ref: &str) -> Result<()> {
2915        let target_commit = self.resolve_reference(target_ref)?;
2916
2917        self.repo
2918            .reset(target_commit.as_object(), git2::ResetType::Soft, None)
2919            .map_err(CascadeError::Git)?;
2920
2921        Ok(())
2922    }
2923
2924    /// Reset working directory and index to match HEAD (hard reset)
2925    /// This clears all uncommitted changes and staged files
2926    pub fn reset_to_head(&self) -> Result<()> {
2927        tracing::debug!("Resetting working directory and index to HEAD");
2928
2929        let head = self.repo.head().map_err(CascadeError::Git)?;
2930        let head_commit = head.peel_to_commit().map_err(CascadeError::Git)?;
2931
2932        // Hard reset: resets index and working tree
2933        let mut checkout_builder = git2::build::CheckoutBuilder::new();
2934        checkout_builder.force(); // Force checkout to overwrite any local changes
2935        checkout_builder.remove_untracked(false); // Don't remove untracked files
2936
2937        self.repo
2938            .reset(
2939                head_commit.as_object(),
2940                git2::ResetType::Hard,
2941                Some(&mut checkout_builder),
2942            )
2943            .map_err(CascadeError::Git)?;
2944
2945        tracing::debug!("Successfully reset working directory to HEAD");
2946        Ok(())
2947    }
2948
2949    /// Find which branch contains a specific commit
2950    pub fn find_branch_containing_commit(&self, commit_hash: &str) -> Result<String> {
2951        let oid = Oid::from_str(commit_hash).map_err(|e| {
2952            CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
2953        })?;
2954
2955        // Get all local branches
2956        let branches = self
2957            .repo
2958            .branches(Some(git2::BranchType::Local))
2959            .map_err(CascadeError::Git)?;
2960
2961        for branch_result in branches {
2962            let (branch, _) = branch_result.map_err(CascadeError::Git)?;
2963
2964            if let Some(branch_name) = branch.name().map_err(CascadeError::Git)? {
2965                // Check if this branch contains the commit
2966                if let Ok(branch_head) = branch.get().peel_to_commit() {
2967                    // Walk the commit history from this branch's HEAD
2968                    let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
2969                    revwalk.push(branch_head.id()).map_err(CascadeError::Git)?;
2970
2971                    for commit_oid in revwalk {
2972                        let commit_oid = commit_oid.map_err(CascadeError::Git)?;
2973                        if commit_oid == oid {
2974                            return Ok(branch_name.to_string());
2975                        }
2976                    }
2977                }
2978            }
2979        }
2980
2981        // If not found in any branch, might be on current HEAD
2982        Err(CascadeError::branch(format!(
2983            "Commit {commit_hash} not found in any local branch"
2984        )))
2985    }
2986
2987    // Async wrappers for potentially blocking operations
2988
2989    /// Fetch from remote origin (async)
2990    pub async fn fetch_async(&self) -> Result<()> {
2991        let repo_path = self.path.clone();
2992        crate::utils::async_ops::run_git_operation(move || {
2993            let repo = GitRepository::open(&repo_path)?;
2994            repo.fetch()
2995        })
2996        .await
2997    }
2998
2999    /// Pull changes from remote (async)
3000    pub async fn pull_async(&self, branch: &str) -> Result<()> {
3001        let repo_path = self.path.clone();
3002        let branch_name = branch.to_string();
3003        crate::utils::async_ops::run_git_operation(move || {
3004            let repo = GitRepository::open(&repo_path)?;
3005            repo.pull(&branch_name)
3006        })
3007        .await
3008    }
3009
3010    /// Push branch to remote (async)
3011    pub async fn push_branch_async(&self, branch_name: &str) -> Result<()> {
3012        let repo_path = self.path.clone();
3013        let branch = branch_name.to_string();
3014        crate::utils::async_ops::run_git_operation(move || {
3015            let repo = GitRepository::open(&repo_path)?;
3016            repo.push(&branch)
3017        })
3018        .await
3019    }
3020
3021    /// Cherry-pick commit (async)
3022    pub async fn cherry_pick_commit_async(&self, commit_hash: &str) -> Result<String> {
3023        let repo_path = self.path.clone();
3024        let hash = commit_hash.to_string();
3025        crate::utils::async_ops::run_git_operation(move || {
3026            let repo = GitRepository::open(&repo_path)?;
3027            repo.cherry_pick(&hash)
3028        })
3029        .await
3030    }
3031
3032    /// Get commit hashes between two refs (async)
3033    pub async fn get_commit_hashes_between_async(
3034        &self,
3035        from: &str,
3036        to: &str,
3037    ) -> Result<Vec<String>> {
3038        let repo_path = self.path.clone();
3039        let from_str = from.to_string();
3040        let to_str = to.to_string();
3041        crate::utils::async_ops::run_git_operation(move || {
3042            let repo = GitRepository::open(&repo_path)?;
3043            let commits = repo.get_commits_between(&from_str, &to_str)?;
3044            Ok(commits.into_iter().map(|c| c.id().to_string()).collect())
3045        })
3046        .await
3047    }
3048
3049    /// Reset a branch to point to a specific commit
3050    pub fn reset_branch_to_commit(&self, branch_name: &str, commit_hash: &str) -> Result<()> {
3051        info!(
3052            "Resetting branch '{}' to commit {}",
3053            branch_name,
3054            &commit_hash[..8]
3055        );
3056
3057        // Find the target commit
3058        let target_oid = git2::Oid::from_str(commit_hash).map_err(|e| {
3059            CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
3060        })?;
3061
3062        let _target_commit = self.repo.find_commit(target_oid).map_err(|e| {
3063            CascadeError::branch(format!("Could not find commit '{commit_hash}': {e}"))
3064        })?;
3065
3066        // Find the branch
3067        let _branch = self
3068            .repo
3069            .find_branch(branch_name, git2::BranchType::Local)
3070            .map_err(|e| {
3071                CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
3072            })?;
3073
3074        // Update the branch reference to point to the target commit
3075        let branch_ref_name = format!("refs/heads/{branch_name}");
3076        self.repo
3077            .reference(
3078                &branch_ref_name,
3079                target_oid,
3080                true,
3081                &format!("Reset {branch_name} to {commit_hash}"),
3082            )
3083            .map_err(|e| {
3084                CascadeError::branch(format!(
3085                    "Could not reset branch '{branch_name}' to commit '{commit_hash}': {e}"
3086                ))
3087            })?;
3088
3089        tracing::info!(
3090            "Successfully reset branch '{}' to commit {}",
3091            branch_name,
3092            &commit_hash[..8]
3093        );
3094        Ok(())
3095    }
3096
3097    /// Detect the parent branch of the current branch using multiple strategies
3098    pub fn detect_parent_branch(&self) -> Result<Option<String>> {
3099        let current_branch = self.get_current_branch()?;
3100
3101        // Strategy 1: Check if current branch has an upstream tracking branch
3102        if let Ok(Some(upstream)) = self.get_upstream_branch(&current_branch) {
3103            // Extract the branch name from "origin/branch-name" format
3104            if let Some(branch_name) = upstream.split('/').nth(1) {
3105                if self.branch_exists(branch_name) {
3106                    tracing::debug!(
3107                        "Detected parent branch '{}' from upstream tracking",
3108                        branch_name
3109                    );
3110                    return Ok(Some(branch_name.to_string()));
3111                }
3112            }
3113        }
3114
3115        // Strategy 2: Use git's default branch detection
3116        if let Ok(default_branch) = self.detect_main_branch() {
3117            // Don't suggest the current branch as its own parent
3118            if current_branch != default_branch {
3119                tracing::debug!(
3120                    "Detected parent branch '{}' as repository default",
3121                    default_branch
3122                );
3123                return Ok(Some(default_branch));
3124            }
3125        }
3126
3127        // Strategy 3: Find the branch with the most recent common ancestor
3128        // Get all local branches and find the one with the shortest commit distance
3129        if let Ok(branches) = self.list_branches() {
3130            let current_commit = self.get_head_commit()?;
3131            let current_commit_hash = current_commit.id().to_string();
3132            let current_oid = current_commit.id();
3133
3134            let mut best_candidate = None;
3135            let mut best_distance = usize::MAX;
3136
3137            for branch in branches {
3138                // Skip the current branch and any branches that look like version branches
3139                if branch == current_branch
3140                    || branch.contains("-v")
3141                    || branch.ends_with("-v2")
3142                    || branch.ends_with("-v3")
3143                {
3144                    continue;
3145                }
3146
3147                if let Ok(base_commit_hash) = self.get_branch_commit_hash(&branch) {
3148                    if let Ok(base_oid) = git2::Oid::from_str(&base_commit_hash) {
3149                        // Find merge base between current branch and this branch
3150                        if let Ok(merge_base_oid) = self.repo.merge_base(current_oid, base_oid) {
3151                            // Count commits from merge base to current head
3152                            if let Ok(distance) = self.count_commits_between(
3153                                &merge_base_oid.to_string(),
3154                                &current_commit_hash,
3155                            ) {
3156                                // Prefer branches with shorter distances (more recent common ancestor)
3157                                // Also prefer branches that look like base branches
3158                                let is_likely_base = self.is_likely_base_branch(&branch);
3159                                let adjusted_distance = if is_likely_base {
3160                                    distance
3161                                } else {
3162                                    distance + 1000
3163                                };
3164
3165                                if adjusted_distance < best_distance {
3166                                    best_distance = adjusted_distance;
3167                                    best_candidate = Some(branch.clone());
3168                                }
3169                            }
3170                        }
3171                    }
3172                }
3173            }
3174
3175            if let Some(ref candidate) = best_candidate {
3176                tracing::debug!(
3177                    "Detected parent branch '{}' with distance {}",
3178                    candidate,
3179                    best_distance
3180                );
3181            }
3182
3183            return Ok(best_candidate);
3184        }
3185
3186        tracing::debug!("Could not detect parent branch for '{}'", current_branch);
3187        Ok(None)
3188    }
3189
3190    /// Check if a branch name looks like a typical base branch
3191    fn is_likely_base_branch(&self, branch_name: &str) -> bool {
3192        let base_patterns = [
3193            "main",
3194            "master",
3195            "develop",
3196            "dev",
3197            "development",
3198            "staging",
3199            "stage",
3200            "release",
3201            "production",
3202            "prod",
3203        ];
3204
3205        base_patterns.contains(&branch_name)
3206    }
3207}
3208
3209#[cfg(test)]
3210mod tests {
3211    use super::*;
3212    use std::process::Command;
3213    use tempfile::TempDir;
3214
3215    fn create_test_repo() -> (TempDir, PathBuf) {
3216        let temp_dir = TempDir::new().unwrap();
3217        let repo_path = temp_dir.path().to_path_buf();
3218
3219        // Initialize git repository
3220        Command::new("git")
3221            .args(["init"])
3222            .current_dir(&repo_path)
3223            .output()
3224            .unwrap();
3225        Command::new("git")
3226            .args(["config", "user.name", "Test"])
3227            .current_dir(&repo_path)
3228            .output()
3229            .unwrap();
3230        Command::new("git")
3231            .args(["config", "user.email", "test@test.com"])
3232            .current_dir(&repo_path)
3233            .output()
3234            .unwrap();
3235
3236        // Create initial commit
3237        std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
3238        Command::new("git")
3239            .args(["add", "."])
3240            .current_dir(&repo_path)
3241            .output()
3242            .unwrap();
3243        Command::new("git")
3244            .args(["commit", "-m", "Initial commit"])
3245            .current_dir(&repo_path)
3246            .output()
3247            .unwrap();
3248
3249        (temp_dir, repo_path)
3250    }
3251
3252    fn create_commit(repo_path: &PathBuf, message: &str, filename: &str) {
3253        let file_path = repo_path.join(filename);
3254        std::fs::write(&file_path, format!("Content for {filename}\n")).unwrap();
3255
3256        Command::new("git")
3257            .args(["add", filename])
3258            .current_dir(repo_path)
3259            .output()
3260            .unwrap();
3261        Command::new("git")
3262            .args(["commit", "-m", message])
3263            .current_dir(repo_path)
3264            .output()
3265            .unwrap();
3266    }
3267
3268    #[test]
3269    fn test_repository_info() {
3270        let (_temp_dir, repo_path) = create_test_repo();
3271        let repo = GitRepository::open(&repo_path).unwrap();
3272
3273        let info = repo.get_info().unwrap();
3274        assert!(!info.is_dirty); // Should be clean after commit
3275        assert!(
3276            info.head_branch == Some("master".to_string())
3277                || info.head_branch == Some("main".to_string()),
3278            "Expected default branch to be 'master' or 'main', got {:?}",
3279            info.head_branch
3280        );
3281        assert!(info.head_commit.is_some()); // Just check it exists
3282        assert!(info.untracked_files.is_empty()); // Should be empty after commit
3283    }
3284
3285    #[test]
3286    fn test_force_push_branch_basic() {
3287        let (_temp_dir, repo_path) = create_test_repo();
3288        let repo = GitRepository::open(&repo_path).unwrap();
3289
3290        // Get the actual default branch name
3291        let default_branch = repo.get_current_branch().unwrap();
3292
3293        // Create source branch with commits
3294        create_commit(&repo_path, "Feature commit 1", "feature1.rs");
3295        Command::new("git")
3296            .args(["checkout", "-b", "source-branch"])
3297            .current_dir(&repo_path)
3298            .output()
3299            .unwrap();
3300        create_commit(&repo_path, "Feature commit 2", "feature2.rs");
3301
3302        // Create target branch
3303        Command::new("git")
3304            .args(["checkout", &default_branch])
3305            .current_dir(&repo_path)
3306            .output()
3307            .unwrap();
3308        Command::new("git")
3309            .args(["checkout", "-b", "target-branch"])
3310            .current_dir(&repo_path)
3311            .output()
3312            .unwrap();
3313        create_commit(&repo_path, "Target commit", "target.rs");
3314
3315        // Test force push from source to target
3316        let result = repo.force_push_branch("target-branch", "source-branch");
3317
3318        // Should succeed in test environment (even though it doesn't actually push to remote)
3319        // The important thing is that the function doesn't panic and handles the git2 operations
3320        assert!(result.is_ok() || result.is_err()); // Either is acceptable for unit test
3321    }
3322
3323    #[test]
3324    fn test_force_push_branch_nonexistent_branches() {
3325        let (_temp_dir, repo_path) = create_test_repo();
3326        let repo = GitRepository::open(&repo_path).unwrap();
3327
3328        // Get the actual default branch name
3329        let default_branch = repo.get_current_branch().unwrap();
3330
3331        // Test force push with nonexistent source branch
3332        let result = repo.force_push_branch("target", "nonexistent-source");
3333        assert!(result.is_err());
3334
3335        // Test force push with nonexistent target branch
3336        let result = repo.force_push_branch("nonexistent-target", &default_branch);
3337        assert!(result.is_err());
3338    }
3339
3340    #[test]
3341    fn test_force_push_workflow_simulation() {
3342        let (_temp_dir, repo_path) = create_test_repo();
3343        let repo = GitRepository::open(&repo_path).unwrap();
3344
3345        // Simulate the smart force push workflow:
3346        // 1. Original branch exists with PR
3347        Command::new("git")
3348            .args(["checkout", "-b", "feature-auth"])
3349            .current_dir(&repo_path)
3350            .output()
3351            .unwrap();
3352        create_commit(&repo_path, "Add authentication", "auth.rs");
3353
3354        // 2. Rebase creates versioned branch
3355        Command::new("git")
3356            .args(["checkout", "-b", "feature-auth-v2"])
3357            .current_dir(&repo_path)
3358            .output()
3359            .unwrap();
3360        create_commit(&repo_path, "Fix auth validation", "auth.rs");
3361
3362        // 3. Smart force push: update original branch from versioned branch
3363        let result = repo.force_push_branch("feature-auth", "feature-auth-v2");
3364
3365        // Verify the operation is handled properly (success or expected error)
3366        match result {
3367            Ok(_) => {
3368                // Force push succeeded - verify branch state if possible
3369                Command::new("git")
3370                    .args(["checkout", "feature-auth"])
3371                    .current_dir(&repo_path)
3372                    .output()
3373                    .unwrap();
3374                let log_output = Command::new("git")
3375                    .args(["log", "--oneline", "-2"])
3376                    .current_dir(&repo_path)
3377                    .output()
3378                    .unwrap();
3379                let log_str = String::from_utf8_lossy(&log_output.stdout);
3380                assert!(
3381                    log_str.contains("Fix auth validation")
3382                        || log_str.contains("Add authentication")
3383                );
3384            }
3385            Err(_) => {
3386                // Expected in test environment without remote - that's fine
3387                // The important thing is we tested the code path without panicking
3388            }
3389        }
3390    }
3391
3392    #[test]
3393    fn test_branch_operations() {
3394        let (_temp_dir, repo_path) = create_test_repo();
3395        let repo = GitRepository::open(&repo_path).unwrap();
3396
3397        // Test get current branch - accept either main or master
3398        let current = repo.get_current_branch().unwrap();
3399        assert!(
3400            current == "master" || current == "main",
3401            "Expected default branch to be 'master' or 'main', got '{current}'"
3402        );
3403
3404        // Test create branch
3405        Command::new("git")
3406            .args(["checkout", "-b", "test-branch"])
3407            .current_dir(&repo_path)
3408            .output()
3409            .unwrap();
3410        let current = repo.get_current_branch().unwrap();
3411        assert_eq!(current, "test-branch");
3412    }
3413
3414    #[test]
3415    fn test_commit_operations() {
3416        let (_temp_dir, repo_path) = create_test_repo();
3417        let repo = GitRepository::open(&repo_path).unwrap();
3418
3419        // Test get head commit
3420        let head = repo.get_head_commit().unwrap();
3421        assert_eq!(head.message().unwrap().trim(), "Initial commit");
3422
3423        // Test get commit by hash
3424        let hash = head.id().to_string();
3425        let same_commit = repo.get_commit(&hash).unwrap();
3426        assert_eq!(head.id(), same_commit.id());
3427    }
3428
3429    #[test]
3430    fn test_checkout_safety_clean_repo() {
3431        let (_temp_dir, repo_path) = create_test_repo();
3432        let repo = GitRepository::open(&repo_path).unwrap();
3433
3434        // Create a test branch
3435        create_commit(&repo_path, "Second commit", "test.txt");
3436        Command::new("git")
3437            .args(["checkout", "-b", "test-branch"])
3438            .current_dir(&repo_path)
3439            .output()
3440            .unwrap();
3441
3442        // Test checkout safety with clean repo
3443        let safety_result = repo.check_checkout_safety("main");
3444        assert!(safety_result.is_ok());
3445        assert!(safety_result.unwrap().is_none()); // Clean repo should return None
3446    }
3447
3448    #[test]
3449    fn test_checkout_safety_with_modified_files() {
3450        let (_temp_dir, repo_path) = create_test_repo();
3451        let repo = GitRepository::open(&repo_path).unwrap();
3452
3453        // Create a test branch
3454        Command::new("git")
3455            .args(["checkout", "-b", "test-branch"])
3456            .current_dir(&repo_path)
3457            .output()
3458            .unwrap();
3459
3460        // Modify a file to create uncommitted changes
3461        std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
3462
3463        // Test checkout safety with modified files
3464        let safety_result = repo.check_checkout_safety("main");
3465        assert!(safety_result.is_ok());
3466        let safety_info = safety_result.unwrap();
3467        assert!(safety_info.is_some());
3468
3469        let info = safety_info.unwrap();
3470        assert!(!info.modified_files.is_empty());
3471        assert!(info.modified_files.contains(&"README.md".to_string()));
3472    }
3473
3474    #[test]
3475    fn test_unsafe_checkout_methods() {
3476        let (_temp_dir, repo_path) = create_test_repo();
3477        let repo = GitRepository::open(&repo_path).unwrap();
3478
3479        // Create a test branch
3480        create_commit(&repo_path, "Second commit", "test.txt");
3481        Command::new("git")
3482            .args(["checkout", "-b", "test-branch"])
3483            .current_dir(&repo_path)
3484            .output()
3485            .unwrap();
3486
3487        // Modify a file to create uncommitted changes
3488        std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
3489
3490        // Test unsafe checkout methods bypass safety checks
3491        let _result = repo.checkout_branch_unsafe("main");
3492        // Note: This might still fail due to git2 restrictions, but shouldn't hit our safety code
3493        // The important thing is that it doesn't trigger our safety confirmation
3494
3495        // Test unsafe commit checkout
3496        let head_commit = repo.get_head_commit().unwrap();
3497        let commit_hash = head_commit.id().to_string();
3498        let _result = repo.checkout_commit_unsafe(&commit_hash);
3499        // Similar to above - testing that safety is bypassed
3500    }
3501
3502    #[test]
3503    fn test_get_modified_files() {
3504        let (_temp_dir, repo_path) = create_test_repo();
3505        let repo = GitRepository::open(&repo_path).unwrap();
3506
3507        // Initially should have no modified files
3508        let modified = repo.get_modified_files().unwrap();
3509        assert!(modified.is_empty());
3510
3511        // Modify a file
3512        std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
3513
3514        // Should now detect the modified file
3515        let modified = repo.get_modified_files().unwrap();
3516        assert_eq!(modified.len(), 1);
3517        assert!(modified.contains(&"README.md".to_string()));
3518    }
3519
3520    #[test]
3521    fn test_get_staged_files() {
3522        let (_temp_dir, repo_path) = create_test_repo();
3523        let repo = GitRepository::open(&repo_path).unwrap();
3524
3525        // Initially should have no staged files
3526        let staged = repo.get_staged_files().unwrap();
3527        assert!(staged.is_empty());
3528
3529        // Create and stage a new file
3530        std::fs::write(repo_path.join("staged.txt"), "Staged content").unwrap();
3531        Command::new("git")
3532            .args(["add", "staged.txt"])
3533            .current_dir(&repo_path)
3534            .output()
3535            .unwrap();
3536
3537        // Should now detect the staged file
3538        let staged = repo.get_staged_files().unwrap();
3539        assert_eq!(staged.len(), 1);
3540        assert!(staged.contains(&"staged.txt".to_string()));
3541    }
3542
3543    #[test]
3544    fn test_create_stash_fallback() {
3545        let (_temp_dir, repo_path) = create_test_repo();
3546        let repo = GitRepository::open(&repo_path).unwrap();
3547
3548        // Test stash creation - newer git versions allow empty stashes
3549        let result = repo.create_stash("test stash");
3550
3551        // Either succeeds (newer git with empty stash) or fails with helpful message
3552        match result {
3553            Ok(stash_id) => {
3554                // Modern git allows empty stashes, verify we got a stash ID
3555                assert!(!stash_id.is_empty());
3556                assert!(stash_id.contains("stash") || stash_id.len() >= 7); // SHA or stash@{n}
3557            }
3558            Err(error) => {
3559                // Older git should fail with helpful message
3560                let error_msg = error.to_string();
3561                assert!(
3562                    error_msg.contains("No local changes to save")
3563                        || error_msg.contains("git stash push")
3564                );
3565            }
3566        }
3567    }
3568
3569    #[test]
3570    fn test_delete_branch_unsafe() {
3571        let (_temp_dir, repo_path) = create_test_repo();
3572        let repo = GitRepository::open(&repo_path).unwrap();
3573
3574        // Create a test branch
3575        create_commit(&repo_path, "Second commit", "test.txt");
3576        Command::new("git")
3577            .args(["checkout", "-b", "test-branch"])
3578            .current_dir(&repo_path)
3579            .output()
3580            .unwrap();
3581
3582        // Add another commit to the test branch to make it different from main
3583        create_commit(&repo_path, "Branch-specific commit", "branch.txt");
3584
3585        // Go back to main
3586        Command::new("git")
3587            .args(["checkout", "main"])
3588            .current_dir(&repo_path)
3589            .output()
3590            .unwrap();
3591
3592        // Test unsafe delete bypasses safety checks
3593        // Note: This may still fail if the branch has unpushed commits, but it should bypass our safety confirmation
3594        let result = repo.delete_branch_unsafe("test-branch");
3595        // Even if it fails, the key is that it didn't prompt for user confirmation
3596        // So we just check that it attempted the operation without interactive prompts
3597        let _ = result; // Don't assert success since delete may fail for git reasons
3598    }
3599
3600    #[test]
3601    fn test_force_push_unsafe() {
3602        let (_temp_dir, repo_path) = create_test_repo();
3603        let repo = GitRepository::open(&repo_path).unwrap();
3604
3605        // Create a test branch
3606        create_commit(&repo_path, "Second commit", "test.txt");
3607        Command::new("git")
3608            .args(["checkout", "-b", "test-branch"])
3609            .current_dir(&repo_path)
3610            .output()
3611            .unwrap();
3612
3613        // Test unsafe force push bypasses safety checks
3614        // Note: This will likely fail due to no remote, but it tests the safety bypass
3615        let _result = repo.force_push_branch_unsafe("test-branch", "test-branch");
3616        // The key is that it doesn't trigger safety confirmation dialogs
3617    }
3618
3619    #[test]
3620    fn test_cherry_pick_basic() {
3621        let (_temp_dir, repo_path) = create_test_repo();
3622        let repo = GitRepository::open(&repo_path).unwrap();
3623
3624        // Create a branch with a commit to cherry-pick
3625        repo.create_branch("source", None).unwrap();
3626        repo.checkout_branch("source").unwrap();
3627
3628        std::fs::write(repo_path.join("cherry.txt"), "Cherry content").unwrap();
3629        Command::new("git")
3630            .args(["add", "."])
3631            .current_dir(&repo_path)
3632            .output()
3633            .unwrap();
3634
3635        Command::new("git")
3636            .args(["commit", "-m", "Cherry commit"])
3637            .current_dir(&repo_path)
3638            .output()
3639            .unwrap();
3640
3641        let cherry_commit = repo.get_head_commit_hash().unwrap();
3642
3643        // Switch back to previous branch (where source was created from)
3644        // Using `git checkout -` is environment-agnostic
3645        Command::new("git")
3646            .args(["checkout", "-"])
3647            .current_dir(&repo_path)
3648            .output()
3649            .unwrap();
3650
3651        repo.create_branch("target", None).unwrap();
3652        repo.checkout_branch("target").unwrap();
3653
3654        // Cherry-pick the commit
3655        let new_commit = repo.cherry_pick(&cherry_commit).unwrap();
3656
3657        // Verify cherry-pick succeeded (commit exists)
3658        repo.repo
3659            .find_commit(git2::Oid::from_str(&new_commit).unwrap())
3660            .unwrap();
3661
3662        // Verify file exists on target branch
3663        assert!(
3664            repo_path.join("cherry.txt").exists(),
3665            "Cherry-picked file should exist"
3666        );
3667
3668        // Verify source branch is unchanged
3669        repo.checkout_branch("source").unwrap();
3670        let source_head = repo.get_head_commit_hash().unwrap();
3671        assert_eq!(
3672            source_head, cherry_commit,
3673            "Source branch should be unchanged"
3674        );
3675    }
3676
3677    #[test]
3678    fn test_cherry_pick_preserves_commit_message() {
3679        let (_temp_dir, repo_path) = create_test_repo();
3680        let repo = GitRepository::open(&repo_path).unwrap();
3681
3682        // Create commit with specific message
3683        repo.create_branch("msg-test", None).unwrap();
3684        repo.checkout_branch("msg-test").unwrap();
3685
3686        std::fs::write(repo_path.join("msg.txt"), "Content").unwrap();
3687        Command::new("git")
3688            .args(["add", "."])
3689            .current_dir(&repo_path)
3690            .output()
3691            .unwrap();
3692
3693        let commit_msg = "Test: Special commit message\n\nWith body";
3694        Command::new("git")
3695            .args(["commit", "-m", commit_msg])
3696            .current_dir(&repo_path)
3697            .output()
3698            .unwrap();
3699
3700        let original_commit = repo.get_head_commit_hash().unwrap();
3701
3702        // Cherry-pick to another branch (use previous branch via git checkout -)
3703        Command::new("git")
3704            .args(["checkout", "-"])
3705            .current_dir(&repo_path)
3706            .output()
3707            .unwrap();
3708        let new_commit = repo.cherry_pick(&original_commit).unwrap();
3709
3710        // Get commit message of new commit
3711        let output = Command::new("git")
3712            .args(["log", "-1", "--format=%B", &new_commit])
3713            .current_dir(&repo_path)
3714            .output()
3715            .unwrap();
3716
3717        let new_msg = String::from_utf8_lossy(&output.stdout);
3718        assert!(
3719            new_msg.contains("Special commit message"),
3720            "Should preserve commit message"
3721        );
3722    }
3723
3724    #[test]
3725    fn test_cherry_pick_handles_conflicts() {
3726        let (_temp_dir, repo_path) = create_test_repo();
3727        let repo = GitRepository::open(&repo_path).unwrap();
3728
3729        // Create conflicting content
3730        std::fs::write(repo_path.join("conflict.txt"), "Original").unwrap();
3731        Command::new("git")
3732            .args(["add", "."])
3733            .current_dir(&repo_path)
3734            .output()
3735            .unwrap();
3736
3737        Command::new("git")
3738            .args(["commit", "-m", "Add conflict file"])
3739            .current_dir(&repo_path)
3740            .output()
3741            .unwrap();
3742
3743        // Create branch with different content
3744        repo.create_branch("conflict-branch", None).unwrap();
3745        repo.checkout_branch("conflict-branch").unwrap();
3746
3747        std::fs::write(repo_path.join("conflict.txt"), "Modified").unwrap();
3748        Command::new("git")
3749            .args(["add", "."])
3750            .current_dir(&repo_path)
3751            .output()
3752            .unwrap();
3753
3754        Command::new("git")
3755            .args(["commit", "-m", "Modify conflict file"])
3756            .current_dir(&repo_path)
3757            .output()
3758            .unwrap();
3759
3760        let conflict_commit = repo.get_head_commit_hash().unwrap();
3761
3762        // Try to cherry-pick (should fail due to conflict)
3763        // Go back to previous branch
3764        Command::new("git")
3765            .args(["checkout", "-"])
3766            .current_dir(&repo_path)
3767            .output()
3768            .unwrap();
3769        std::fs::write(repo_path.join("conflict.txt"), "Different").unwrap();
3770        Command::new("git")
3771            .args(["add", "."])
3772            .current_dir(&repo_path)
3773            .output()
3774            .unwrap();
3775
3776        Command::new("git")
3777            .args(["commit", "-m", "Different change"])
3778            .current_dir(&repo_path)
3779            .output()
3780            .unwrap();
3781
3782        // Cherry-pick should fail with conflict
3783        let result = repo.cherry_pick(&conflict_commit);
3784        assert!(result.is_err(), "Cherry-pick with conflict should fail");
3785    }
3786
3787    #[test]
3788    fn test_reset_to_head_clears_staged_files() {
3789        let (_temp_dir, repo_path) = create_test_repo();
3790        let repo = GitRepository::open(&repo_path).unwrap();
3791
3792        // Create and stage some files
3793        std::fs::write(repo_path.join("staged1.txt"), "Content 1").unwrap();
3794        std::fs::write(repo_path.join("staged2.txt"), "Content 2").unwrap();
3795
3796        Command::new("git")
3797            .args(["add", "staged1.txt", "staged2.txt"])
3798            .current_dir(&repo_path)
3799            .output()
3800            .unwrap();
3801
3802        // Verify files are staged
3803        let staged_before = repo.get_staged_files().unwrap();
3804        assert_eq!(staged_before.len(), 2, "Should have 2 staged files");
3805
3806        // Reset to HEAD
3807        repo.reset_to_head().unwrap();
3808
3809        // Verify no files are staged after reset
3810        let staged_after = repo.get_staged_files().unwrap();
3811        assert_eq!(
3812            staged_after.len(),
3813            0,
3814            "Should have no staged files after reset"
3815        );
3816    }
3817
3818    #[test]
3819    fn test_reset_to_head_clears_modified_files() {
3820        let (_temp_dir, repo_path) = create_test_repo();
3821        let repo = GitRepository::open(&repo_path).unwrap();
3822
3823        // Modify an existing file
3824        std::fs::write(repo_path.join("README.md"), "# Modified content").unwrap();
3825
3826        // Stage the modification
3827        Command::new("git")
3828            .args(["add", "README.md"])
3829            .current_dir(&repo_path)
3830            .output()
3831            .unwrap();
3832
3833        // Verify file is modified and staged
3834        assert!(repo.is_dirty().unwrap(), "Repo should be dirty");
3835
3836        // Reset to HEAD
3837        repo.reset_to_head().unwrap();
3838
3839        // Verify repo is clean
3840        assert!(
3841            !repo.is_dirty().unwrap(),
3842            "Repo should be clean after reset"
3843        );
3844
3845        // Verify file content is restored
3846        let content = std::fs::read_to_string(repo_path.join("README.md")).unwrap();
3847        assert_eq!(
3848            content, "# Test",
3849            "File should be restored to original content"
3850        );
3851    }
3852
3853    #[test]
3854    fn test_reset_to_head_preserves_untracked_files() {
3855        let (_temp_dir, repo_path) = create_test_repo();
3856        let repo = GitRepository::open(&repo_path).unwrap();
3857
3858        // Create untracked file
3859        std::fs::write(repo_path.join("untracked.txt"), "Untracked content").unwrap();
3860
3861        // Stage some other file
3862        std::fs::write(repo_path.join("staged.txt"), "Staged content").unwrap();
3863        Command::new("git")
3864            .args(["add", "staged.txt"])
3865            .current_dir(&repo_path)
3866            .output()
3867            .unwrap();
3868
3869        // Reset to HEAD
3870        repo.reset_to_head().unwrap();
3871
3872        // Verify untracked file still exists
3873        assert!(
3874            repo_path.join("untracked.txt").exists(),
3875            "Untracked file should be preserved"
3876        );
3877
3878        // Verify staged file was removed (since it was never committed)
3879        assert!(
3880            !repo_path.join("staged.txt").exists(),
3881            "Staged but uncommitted file should be removed"
3882        );
3883    }
3884
3885    #[test]
3886    fn test_cherry_pick_does_not_modify_source() {
3887        let (_temp_dir, repo_path) = create_test_repo();
3888        let repo = GitRepository::open(&repo_path).unwrap();
3889
3890        // Create source branch with multiple commits
3891        repo.create_branch("feature", None).unwrap();
3892        repo.checkout_branch("feature").unwrap();
3893
3894        // Add multiple commits
3895        for i in 1..=3 {
3896            std::fs::write(
3897                repo_path.join(format!("file{i}.txt")),
3898                format!("Content {i}"),
3899            )
3900            .unwrap();
3901            Command::new("git")
3902                .args(["add", "."])
3903                .current_dir(&repo_path)
3904                .output()
3905                .unwrap();
3906
3907            Command::new("git")
3908                .args(["commit", "-m", &format!("Commit {i}")])
3909                .current_dir(&repo_path)
3910                .output()
3911                .unwrap();
3912        }
3913
3914        // Get source branch state
3915        let source_commits = Command::new("git")
3916            .args(["log", "--format=%H", "feature"])
3917            .current_dir(&repo_path)
3918            .output()
3919            .unwrap();
3920        let source_state = String::from_utf8_lossy(&source_commits.stdout).to_string();
3921
3922        // Cherry-pick middle commit to another branch
3923        let commits: Vec<&str> = source_state.lines().collect();
3924        let middle_commit = commits[1];
3925
3926        // Go back to previous branch
3927        Command::new("git")
3928            .args(["checkout", "-"])
3929            .current_dir(&repo_path)
3930            .output()
3931            .unwrap();
3932        repo.create_branch("target", None).unwrap();
3933        repo.checkout_branch("target").unwrap();
3934
3935        repo.cherry_pick(middle_commit).unwrap();
3936
3937        // Verify source branch is completely unchanged
3938        let after_commits = Command::new("git")
3939            .args(["log", "--format=%H", "feature"])
3940            .current_dir(&repo_path)
3941            .output()
3942            .unwrap();
3943        let after_state = String::from_utf8_lossy(&after_commits.stdout).to_string();
3944
3945        assert_eq!(
3946            source_state, after_state,
3947            "Source branch should be completely unchanged after cherry-pick"
3948        );
3949    }
3950
3951    #[test]
3952    fn test_detect_parent_branch() {
3953        let (_temp_dir, repo_path) = create_test_repo();
3954        let repo = GitRepository::open(&repo_path).unwrap();
3955
3956        // Create a custom base branch (not just main/master)
3957        repo.create_branch("dev123", None).unwrap();
3958        repo.checkout_branch("dev123").unwrap();
3959        create_commit(&repo_path, "Base commit on dev123", "base.txt");
3960
3961        // Create feature branch from dev123
3962        repo.create_branch("feature-branch", None).unwrap();
3963        repo.checkout_branch("feature-branch").unwrap();
3964        create_commit(&repo_path, "Feature commit", "feature.txt");
3965
3966        // Should detect dev123 as parent since it's the most recent common ancestor
3967        let detected_parent = repo.detect_parent_branch().unwrap();
3968
3969        // The algorithm should find dev123 through either Strategy 2 (default branch)
3970        // or Strategy 3 (common ancestor analysis)
3971        assert!(detected_parent.is_some(), "Should detect a parent branch");
3972
3973        // Since we can't guarantee which strategy will work in the test environment,
3974        // just verify it returns something reasonable
3975        let parent = detected_parent.unwrap();
3976        assert!(
3977            parent == "dev123" || parent == "main" || parent == "master",
3978            "Parent should be dev123, main, or master, got: {parent}"
3979        );
3980    }
3981}