cascade_cli/git/
repository.rs

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