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            if let Some(username) = username_from_url {
592                // Try SSH key first
593                git2::Cred::ssh_key_from_agent(username)
594            } else {
595                // Try default credential helper
596                git2::Cred::default()
597            }
598        });
599
600        // Configure SSL certificate checking with system certificates by default
601        // This matches what tools like Graphite, Sapling, and Phabricator do
602        // Priority: 1. Use system certificates (default), 2. Manual overrides only if needed
603
604        let mut ssl_configured = false;
605
606        // Check for manual SSL overrides first (only when user explicitly needs them)
607        if let Some(ssl_config) = &self.ssl_config {
608            if ssl_config.accept_invalid_certs {
609                tracing::warn!(
610                    "SSL certificate verification DISABLED via Cascade config - this is insecure!"
611                );
612                callbacks.certificate_check(|_cert, _host| {
613                    tracing::debug!("⚠️  Accepting invalid certificate for host: {}", _host);
614                    Ok(git2::CertificateCheckStatus::CertificateOk)
615                });
616                ssl_configured = true;
617            } else if let Some(ca_path) = &ssl_config.ca_bundle_path {
618                tracing::info!("Using custom CA bundle from Cascade config: {}", ca_path);
619                callbacks.certificate_check(|_cert, host| {
620                    tracing::debug!("Using custom CA bundle for host: {}", host);
621                    Ok(git2::CertificateCheckStatus::CertificateOk)
622                });
623                ssl_configured = true;
624            }
625        }
626
627        // Check git config for manual overrides
628        if !ssl_configured {
629            if let Ok(config) = self.repo.config() {
630                let ssl_verify = config.get_bool("http.sslVerify").unwrap_or(true);
631
632                if !ssl_verify {
633                    tracing::warn!(
634                        "SSL certificate verification DISABLED via git config - this is insecure!"
635                    );
636                    callbacks.certificate_check(|_cert, host| {
637                        tracing::debug!("⚠️  Bypassing SSL verification for host: {}", host);
638                        Ok(git2::CertificateCheckStatus::CertificateOk)
639                    });
640                    ssl_configured = true;
641                } else if let Ok(ca_path) = config.get_string("http.sslCAInfo") {
642                    tracing::info!("Using custom CA bundle from git config: {}", ca_path);
643                    callbacks.certificate_check(|_cert, host| {
644                        tracing::debug!("Using git config CA bundle for host: {}", host);
645                        Ok(git2::CertificateCheckStatus::CertificateOk)
646                    });
647                    ssl_configured = true;
648                }
649            }
650        }
651
652        // DEFAULT BEHAVIOR: Use system certificates (like git CLI and other modern tools)
653        // This should work out-of-the-box in corporate environments
654        if !ssl_configured {
655            tracing::debug!(
656                "Using system certificate store for SSL verification (default behavior)"
657            );
658
659            // For macOS with SecureTransport backend, try default certificate validation first
660            if cfg!(target_os = "macos") {
661                tracing::debug!("macOS detected - using default certificate validation");
662                // Don't set any certificate callback - let git2 use its default behavior
663                // This often works better with SecureTransport backend on macOS
664            } else {
665                // Use CertificatePassthrough for other platforms
666                callbacks.certificate_check(|_cert, host| {
667                    tracing::debug!("System certificate validation for host: {}", host);
668                    Ok(git2::CertificateCheckStatus::CertificatePassthrough)
669                });
670            }
671        }
672
673        Ok(callbacks)
674    }
675
676    /// Get the tree ID from the current index
677    fn get_index_tree(&self) -> Result<Oid> {
678        let mut index = self.repo.index().map_err(CascadeError::Git)?;
679
680        index.write_tree().map_err(CascadeError::Git)
681    }
682
683    /// Get repository status
684    pub fn get_status(&self) -> Result<git2::Statuses<'_>> {
685        self.repo.statuses(None).map_err(CascadeError::Git)
686    }
687
688    /// Get remote URL for a given remote name
689    pub fn get_remote_url(&self, name: &str) -> Result<String> {
690        let remote = self.repo.find_remote(name).map_err(CascadeError::Git)?;
691
692        let url = remote.url().ok_or_else(|| {
693            CascadeError::Git(git2::Error::from_str("Remote URL is not valid UTF-8"))
694        })?;
695
696        Ok(url.to_string())
697    }
698
699    /// Cherry-pick a commit onto the current branch
700    pub fn cherry_pick(&self, commit_hash: &str) -> Result<String> {
701        tracing::debug!("Cherry-picking commit {}", commit_hash);
702
703        let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
704        let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
705
706        // Get the commit's tree
707        let commit_tree = commit.tree().map_err(CascadeError::Git)?;
708
709        // Get parent tree for merge base
710        let parent_commit = if commit.parent_count() > 0 {
711            commit.parent(0).map_err(CascadeError::Git)?
712        } else {
713            // Root commit - use empty tree
714            let empty_tree_oid = self.repo.treebuilder(None)?.write()?;
715            let empty_tree = self.repo.find_tree(empty_tree_oid)?;
716            let sig = self.get_signature()?;
717            return self
718                .repo
719                .commit(
720                    Some("HEAD"),
721                    &sig,
722                    &sig,
723                    commit.message().unwrap_or("Cherry-picked commit"),
724                    &empty_tree,
725                    &[],
726                )
727                .map(|oid| oid.to_string())
728                .map_err(CascadeError::Git);
729        };
730
731        let parent_tree = parent_commit.tree().map_err(CascadeError::Git)?;
732
733        // Get current HEAD tree for 3-way merge
734        let head_commit = self.get_head_commit()?;
735        let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
736
737        // Perform 3-way merge
738        let mut index = self
739            .repo
740            .merge_trees(&parent_tree, &head_tree, &commit_tree, None)
741            .map_err(CascadeError::Git)?;
742
743        // Check for conflicts
744        if index.has_conflicts() {
745            return Err(CascadeError::branch(format!(
746                "Cherry-pick of {commit_hash} has conflicts that need manual resolution"
747            )));
748        }
749
750        // Write merged tree
751        let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
752        let merged_tree = self
753            .repo
754            .find_tree(merged_tree_oid)
755            .map_err(CascadeError::Git)?;
756
757        // Create new commit
758        let signature = self.get_signature()?;
759        let message = format!("Cherry-pick: {}", commit.message().unwrap_or(""));
760
761        let new_commit_oid = self
762            .repo
763            .commit(
764                Some("HEAD"),
765                &signature,
766                &signature,
767                &message,
768                &merged_tree,
769                &[&head_commit],
770            )
771            .map_err(CascadeError::Git)?;
772
773        tracing::info!("Cherry-picked {} -> {}", commit_hash, new_commit_oid);
774        Ok(new_commit_oid.to_string())
775    }
776
777    /// Check for merge conflicts in the index
778    pub fn has_conflicts(&self) -> Result<bool> {
779        let index = self.repo.index().map_err(CascadeError::Git)?;
780        Ok(index.has_conflicts())
781    }
782
783    /// Get list of conflicted files
784    pub fn get_conflicted_files(&self) -> Result<Vec<String>> {
785        let index = self.repo.index().map_err(CascadeError::Git)?;
786
787        let mut conflicts = Vec::new();
788
789        // Iterate through index conflicts
790        let conflict_iter = index.conflicts().map_err(CascadeError::Git)?;
791
792        for conflict in conflict_iter {
793            let conflict = conflict.map_err(CascadeError::Git)?;
794            if let Some(our) = conflict.our {
795                if let Ok(path) = std::str::from_utf8(&our.path) {
796                    conflicts.push(path.to_string());
797                }
798            } else if let Some(their) = conflict.their {
799                if let Ok(path) = std::str::from_utf8(&their.path) {
800                    conflicts.push(path.to_string());
801                }
802            }
803        }
804
805        Ok(conflicts)
806    }
807
808    /// Fetch from remote origin
809    pub fn fetch(&self) -> Result<()> {
810        tracing::info!("Fetching from origin");
811
812        let mut remote = self
813            .repo
814            .find_remote("origin")
815            .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
816
817        // Configure callbacks with SSL settings from git config
818        let callbacks = self.configure_remote_callbacks()?;
819
820        // Fetch options with authentication and SSL config
821        let mut fetch_options = git2::FetchOptions::new();
822        fetch_options.remote_callbacks(callbacks);
823
824        // Fetch with authentication
825        match remote.fetch::<&str>(&[], Some(&mut fetch_options), None) {
826            Ok(_) => {
827                tracing::debug!("Fetch completed successfully");
828                Ok(())
829            }
830            Err(e) => {
831                // Check if this is a TLS/SSL error that might be resolved by falling back to git CLI
832                let error_string = e.to_string();
833                if error_string.contains("TLS stream") || error_string.contains("SSL") {
834                    tracing::warn!(
835                        "git2 TLS error detected: {}, falling back to git CLI for fetch operation",
836                        e
837                    );
838                    return self.fetch_with_git_cli();
839                }
840                Err(CascadeError::Git(e))
841            }
842        }
843    }
844
845    /// Pull changes from remote (fetch + merge)
846    pub fn pull(&self, branch: &str) -> Result<()> {
847        tracing::info!("Pulling branch: {}", branch);
848
849        // First fetch - this now includes TLS fallback
850        match self.fetch() {
851            Ok(_) => {}
852            Err(e) => {
853                // If fetch failed even with CLI fallback, try full git pull as last resort
854                let error_string = e.to_string();
855                if error_string.contains("TLS stream") || error_string.contains("SSL") {
856                    tracing::warn!(
857                        "git2 TLS error detected: {}, falling back to git CLI for pull operation",
858                        e
859                    );
860                    return self.pull_with_git_cli(branch);
861                }
862                return Err(e);
863            }
864        }
865
866        // Get remote tracking branch
867        let remote_branch_name = format!("origin/{branch}");
868        let remote_oid = self
869            .repo
870            .refname_to_id(&format!("refs/remotes/{remote_branch_name}"))
871            .map_err(|e| {
872                CascadeError::branch(format!("Remote branch {remote_branch_name} not found: {e}"))
873            })?;
874
875        let remote_commit = self
876            .repo
877            .find_commit(remote_oid)
878            .map_err(CascadeError::Git)?;
879
880        // Get current HEAD
881        let head_commit = self.get_head_commit()?;
882
883        // Check if we need to merge
884        if head_commit.id() == remote_commit.id() {
885            tracing::debug!("Already up to date");
886            return Ok(());
887        }
888
889        // Perform merge
890        let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
891        let remote_tree = remote_commit.tree().map_err(CascadeError::Git)?;
892
893        // Find merge base
894        let merge_base_oid = self
895            .repo
896            .merge_base(head_commit.id(), remote_commit.id())
897            .map_err(CascadeError::Git)?;
898        let merge_base_commit = self
899            .repo
900            .find_commit(merge_base_oid)
901            .map_err(CascadeError::Git)?;
902        let merge_base_tree = merge_base_commit.tree().map_err(CascadeError::Git)?;
903
904        // 3-way merge
905        let mut index = self
906            .repo
907            .merge_trees(&merge_base_tree, &head_tree, &remote_tree, None)
908            .map_err(CascadeError::Git)?;
909
910        if index.has_conflicts() {
911            return Err(CascadeError::branch(
912                "Pull has conflicts that need manual resolution".to_string(),
913            ));
914        }
915
916        // Write merged tree and create merge commit
917        let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
918        let merged_tree = self
919            .repo
920            .find_tree(merged_tree_oid)
921            .map_err(CascadeError::Git)?;
922
923        let signature = self.get_signature()?;
924        let message = format!("Merge branch '{branch}' from origin");
925
926        self.repo
927            .commit(
928                Some("HEAD"),
929                &signature,
930                &signature,
931                &message,
932                &merged_tree,
933                &[&head_commit, &remote_commit],
934            )
935            .map_err(CascadeError::Git)?;
936
937        tracing::info!("Pull completed successfully");
938        Ok(())
939    }
940
941    /// Push current branch to remote
942    pub fn push(&self, branch: &str) -> Result<()> {
943        tracing::info!("Pushing branch: {}", branch);
944
945        let mut remote = self
946            .repo
947            .find_remote("origin")
948            .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
949
950        let remote_url = remote.url().unwrap_or("unknown").to_string();
951        tracing::debug!("Remote URL: {}", remote_url);
952
953        let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
954        tracing::debug!("Push refspec: {}", refspec);
955
956        // Configure callbacks with enhanced SSL settings and error handling
957        let mut callbacks = self.configure_remote_callbacks()?;
958
959        // Add enhanced progress and error callbacks for better debugging
960        callbacks.push_update_reference(|refname, status| {
961            if let Some(msg) = status {
962                tracing::error!("Push failed for ref {}: {}", refname, msg);
963                return Err(git2::Error::from_str(&format!("Push failed: {msg}")));
964            }
965            tracing::debug!("Push succeeded for ref: {}", refname);
966            Ok(())
967        });
968
969        // Push options with authentication and SSL config
970        let mut push_options = git2::PushOptions::new();
971        push_options.remote_callbacks(callbacks);
972
973        // Attempt push with enhanced error reporting
974        match remote.push(&[&refspec], Some(&mut push_options)) {
975            Ok(_) => {
976                tracing::info!("Push completed successfully for branch: {}", branch);
977                Ok(())
978            }
979            Err(e) => {
980                // Check if this is a TLS/SSL error that might be resolved by falling back to git CLI
981                let error_string = e.to_string();
982                tracing::debug!("git2 push error: {} (class: {:?})", error_string, e.class());
983
984                if error_string.contains("TLS stream")
985                    || error_string.contains("SSL")
986                    || e.class() == git2::ErrorClass::Ssl
987                {
988                    tracing::info!(
989                        "git2 TLS/SSL error: {}, falling back to git CLI for push operation",
990                        e
991                    );
992                    return self.push_with_git_cli(branch);
993                }
994
995                // Enhanced error message with debugging hints
996                let error_msg = format!(
997                    "Failed to push branch '{}' to remote '{}': {}. \
998                    \nDebugging hints:\
999                    \n  - Check network connectivity: ping {}\
1000                    \n  - Verify authentication: git remote -v\
1001                    \n  - Test manual push: git push origin {}\
1002                    \n  - Check SSL settings if using HTTPS\
1003                    \n  - For corporate networks, consider SSL certificate configuration",
1004                    branch,
1005                    remote_url,
1006                    e,
1007                    remote_url.split("://").nth(1).unwrap_or("unknown"),
1008                    branch
1009                );
1010
1011                tracing::error!("{}", error_msg);
1012                Err(CascadeError::branch(error_msg))
1013            }
1014        }
1015    }
1016
1017    /// Fallback push method using git CLI instead of git2
1018    /// This is used when git2 has TLS/SSL issues but git CLI works fine
1019    fn push_with_git_cli(&self, branch: &str) -> Result<()> {
1020        tracing::info!("Using git CLI fallback for push operation: {}", branch);
1021
1022        let output = std::process::Command::new("git")
1023            .args(["push", "origin", branch])
1024            .current_dir(&self.path)
1025            .output()
1026            .map_err(|e| CascadeError::branch(format!("Failed to execute git command: {e}")))?;
1027
1028        if output.status.success() {
1029            tracing::info!("✅ Git CLI push succeeded for branch: {}", branch);
1030            Ok(())
1031        } else {
1032            let stderr = String::from_utf8_lossy(&output.stderr);
1033            let stdout = String::from_utf8_lossy(&output.stdout);
1034            let error_msg = format!(
1035                "Git CLI push failed for branch '{}': {}\nStdout: {}\nStderr: {}",
1036                branch, output.status, stdout, stderr
1037            );
1038            tracing::error!("{}", error_msg);
1039            Err(CascadeError::branch(error_msg))
1040        }
1041    }
1042
1043    /// Fallback fetch method using git CLI instead of git2
1044    /// This is used when git2 has TLS/SSL issues but git CLI works fine
1045    fn fetch_with_git_cli(&self) -> Result<()> {
1046        tracing::info!("Using git CLI fallback for fetch operation");
1047
1048        let output = std::process::Command::new("git")
1049            .args(["fetch", "origin"])
1050            .current_dir(&self.path)
1051            .output()
1052            .map_err(|e| {
1053                CascadeError::Git(git2::Error::from_str(&format!(
1054                    "Failed to execute git command: {e}"
1055                )))
1056            })?;
1057
1058        if output.status.success() {
1059            tracing::info!("✅ Git CLI fetch succeeded");
1060            Ok(())
1061        } else {
1062            let stderr = String::from_utf8_lossy(&output.stderr);
1063            let stdout = String::from_utf8_lossy(&output.stdout);
1064            let error_msg = format!(
1065                "Git CLI fetch failed: {}\nStdout: {}\nStderr: {}",
1066                output.status, stdout, stderr
1067            );
1068            tracing::error!("{}", error_msg);
1069            Err(CascadeError::Git(git2::Error::from_str(&error_msg)))
1070        }
1071    }
1072
1073    /// Fallback pull method using git CLI instead of git2
1074    /// This is used when git2 has TLS/SSL issues but git CLI works fine
1075    fn pull_with_git_cli(&self, branch: &str) -> Result<()> {
1076        tracing::info!("Using git CLI fallback for pull operation: {}", branch);
1077
1078        let output = std::process::Command::new("git")
1079            .args(["pull", "origin", branch])
1080            .current_dir(&self.path)
1081            .output()
1082            .map_err(|e| {
1083                CascadeError::Git(git2::Error::from_str(&format!(
1084                    "Failed to execute git command: {e}"
1085                )))
1086            })?;
1087
1088        if output.status.success() {
1089            tracing::info!("✅ Git CLI pull succeeded for branch: {}", branch);
1090            Ok(())
1091        } else {
1092            let stderr = String::from_utf8_lossy(&output.stderr);
1093            let stdout = String::from_utf8_lossy(&output.stdout);
1094            let error_msg = format!(
1095                "Git CLI pull failed for branch '{}': {}\nStdout: {}\nStderr: {}",
1096                branch, output.status, stdout, stderr
1097            );
1098            tracing::error!("{}", error_msg);
1099            Err(CascadeError::Git(git2::Error::from_str(&error_msg)))
1100        }
1101    }
1102
1103    /// Fallback force push method using git CLI instead of git2
1104    /// This is used when git2 has TLS/SSL issues but git CLI works fine
1105    fn force_push_with_git_cli(&self, branch: &str) -> Result<()> {
1106        tracing::info!(
1107            "Using git CLI fallback for force push operation: {}",
1108            branch
1109        );
1110
1111        let output = std::process::Command::new("git")
1112            .args(["push", "--force", "origin", branch])
1113            .current_dir(&self.path)
1114            .output()
1115            .map_err(|e| CascadeError::branch(format!("Failed to execute git command: {e}")))?;
1116
1117        if output.status.success() {
1118            tracing::info!("✅ Git CLI force push succeeded for branch: {}", branch);
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 force push failed for branch '{}': {}\nStdout: {}\nStderr: {}",
1125                branch, output.status, stdout, stderr
1126            );
1127            tracing::error!("{}", error_msg);
1128            Err(CascadeError::branch(error_msg))
1129        }
1130    }
1131
1132    /// Delete a local branch
1133    pub fn delete_branch(&self, name: &str) -> Result<()> {
1134        self.delete_branch_with_options(name, false)
1135    }
1136
1137    /// Delete a local branch with force option to bypass safety checks
1138    pub fn delete_branch_unsafe(&self, name: &str) -> Result<()> {
1139        self.delete_branch_with_options(name, true)
1140    }
1141
1142    /// Internal branch deletion implementation with safety options
1143    fn delete_branch_with_options(&self, name: &str, force_unsafe: bool) -> Result<()> {
1144        info!("Attempting to delete branch: {}", name);
1145
1146        // Enhanced safety check: Detect unpushed commits before deletion
1147        if !force_unsafe {
1148            let safety_result = self.check_branch_deletion_safety(name)?;
1149            if let Some(safety_info) = safety_result {
1150                // Branch has unpushed commits, get user confirmation
1151                self.handle_branch_deletion_confirmation(name, &safety_info)?;
1152            }
1153        }
1154
1155        let mut branch = self
1156            .repo
1157            .find_branch(name, git2::BranchType::Local)
1158            .map_err(|e| CascadeError::branch(format!("Could not find branch '{name}': {e}")))?;
1159
1160        branch
1161            .delete()
1162            .map_err(|e| CascadeError::branch(format!("Could not delete branch '{name}': {e}")))?;
1163
1164        info!("Successfully deleted branch '{}'", name);
1165        Ok(())
1166    }
1167
1168    /// Get commits between two references
1169    pub fn get_commits_between(&self, from: &str, to: &str) -> Result<Vec<git2::Commit<'_>>> {
1170        let from_oid = self
1171            .repo
1172            .refname_to_id(&format!("refs/heads/{from}"))
1173            .or_else(|_| Oid::from_str(from))
1174            .map_err(|e| CascadeError::branch(format!("Invalid from reference '{from}': {e}")))?;
1175
1176        let to_oid = self
1177            .repo
1178            .refname_to_id(&format!("refs/heads/{to}"))
1179            .or_else(|_| Oid::from_str(to))
1180            .map_err(|e| CascadeError::branch(format!("Invalid to reference '{to}': {e}")))?;
1181
1182        let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
1183
1184        revwalk.push(to_oid).map_err(CascadeError::Git)?;
1185        revwalk.hide(from_oid).map_err(CascadeError::Git)?;
1186
1187        let mut commits = Vec::new();
1188        for oid in revwalk {
1189            let oid = oid.map_err(CascadeError::Git)?;
1190            let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
1191            commits.push(commit);
1192        }
1193
1194        Ok(commits)
1195    }
1196
1197    /// Force push one branch's content to another branch name
1198    /// This is used to preserve PR history while updating branch contents after rebase
1199    pub fn force_push_branch(&self, target_branch: &str, source_branch: &str) -> Result<()> {
1200        self.force_push_branch_with_options(target_branch, source_branch, false)
1201    }
1202
1203    /// Force push with explicit force flag to bypass safety checks
1204    pub fn force_push_branch_unsafe(&self, target_branch: &str, source_branch: &str) -> Result<()> {
1205        self.force_push_branch_with_options(target_branch, source_branch, true)
1206    }
1207
1208    /// Internal force push implementation with safety options
1209    fn force_push_branch_with_options(
1210        &self,
1211        target_branch: &str,
1212        source_branch: &str,
1213        force_unsafe: bool,
1214    ) -> Result<()> {
1215        info!(
1216            "Force pushing {} content to {} to preserve PR history",
1217            source_branch, target_branch
1218        );
1219
1220        // Enhanced safety check: Detect potential data loss and get user confirmation
1221        if !force_unsafe {
1222            let safety_result = self.check_force_push_safety_enhanced(target_branch)?;
1223            if let Some(backup_info) = safety_result {
1224                // Create backup branch before force push
1225                self.create_backup_branch(target_branch, &backup_info.remote_commit_id)?;
1226                info!(
1227                    "✅ Created backup branch: {}",
1228                    backup_info.backup_branch_name
1229                );
1230            }
1231        }
1232
1233        // First, ensure we have the latest changes for the source branch
1234        let source_ref = self
1235            .repo
1236            .find_reference(&format!("refs/heads/{source_branch}"))
1237            .map_err(|e| {
1238                CascadeError::config(format!("Failed to find source branch {source_branch}: {e}"))
1239            })?;
1240        let source_commit = source_ref.peel_to_commit().map_err(|e| {
1241            CascadeError::config(format!(
1242                "Failed to get commit for source branch {source_branch}: {e}"
1243            ))
1244        })?;
1245
1246        // Update the target branch to point to the source commit
1247        let mut target_ref = self
1248            .repo
1249            .find_reference(&format!("refs/heads/{target_branch}"))
1250            .map_err(|e| {
1251                CascadeError::config(format!("Failed to find target branch {target_branch}: {e}"))
1252            })?;
1253
1254        target_ref
1255            .set_target(source_commit.id(), "Force push from rebase")
1256            .map_err(|e| {
1257                CascadeError::config(format!(
1258                    "Failed to update target branch {target_branch}: {e}"
1259                ))
1260            })?;
1261
1262        // Force push to remote
1263        let mut remote = self
1264            .repo
1265            .find_remote("origin")
1266            .map_err(|e| CascadeError::config(format!("Failed to find origin remote: {e}")))?;
1267
1268        let refspec = format!("+refs/heads/{target_branch}:refs/heads/{target_branch}");
1269
1270        // Configure callbacks with SSL settings from git config
1271        let callbacks = self.configure_remote_callbacks()?;
1272
1273        // Push options for force push with SSL config
1274        let mut push_options = git2::PushOptions::new();
1275        push_options.remote_callbacks(callbacks);
1276
1277        match remote.push(&[&refspec], Some(&mut push_options)) {
1278            Ok(_) => {}
1279            Err(e) => {
1280                // Check if this is a TLS/SSL error that might be resolved by falling back to git CLI
1281                let error_string = e.to_string();
1282                if error_string.contains("TLS stream") || error_string.contains("SSL") {
1283                    tracing::warn!(
1284                        "git2 TLS error detected: {}, falling back to git CLI for force push operation",
1285                        e
1286                    );
1287                    return self.force_push_with_git_cli(target_branch);
1288                }
1289                return Err(CascadeError::config(format!(
1290                    "Failed to force push {target_branch}: {e}"
1291                )));
1292            }
1293        }
1294
1295        info!(
1296            "✅ Successfully force pushed {} to preserve PR history",
1297            target_branch
1298        );
1299        Ok(())
1300    }
1301
1302    /// Enhanced safety check for force push operations with user confirmation
1303    /// Returns backup info if data would be lost and user confirms
1304    fn check_force_push_safety_enhanced(
1305        &self,
1306        target_branch: &str,
1307    ) -> Result<Option<ForceBackupInfo>> {
1308        // First fetch latest remote changes to ensure we have up-to-date information
1309        match self.fetch() {
1310            Ok(_) => {}
1311            Err(e) => {
1312                // If fetch fails, warn but don't block the operation
1313                warn!("Could not fetch latest changes for safety check: {}", e);
1314            }
1315        }
1316
1317        // Check if there are commits on the remote that would be lost
1318        let remote_ref = format!("refs/remotes/origin/{target_branch}");
1319        let local_ref = format!("refs/heads/{target_branch}");
1320
1321        // Try to find both local and remote references
1322        let local_commit = match self.repo.find_reference(&local_ref) {
1323            Ok(reference) => reference.peel_to_commit().ok(),
1324            Err(_) => None,
1325        };
1326
1327        let remote_commit = match self.repo.find_reference(&remote_ref) {
1328            Ok(reference) => reference.peel_to_commit().ok(),
1329            Err(_) => None,
1330        };
1331
1332        // If we have both commits, check for divergence
1333        if let (Some(local), Some(remote)) = (local_commit, remote_commit) {
1334            if local.id() != remote.id() {
1335                // Check if the remote has commits that the local doesn't have
1336                let merge_base_oid = self
1337                    .repo
1338                    .merge_base(local.id(), remote.id())
1339                    .map_err(|e| CascadeError::config(format!("Failed to find merge base: {e}")))?;
1340
1341                // If merge base != remote commit, remote has commits that would be lost
1342                if merge_base_oid != remote.id() {
1343                    let commits_to_lose = self.count_commits_between(
1344                        &merge_base_oid.to_string(),
1345                        &remote.id().to_string(),
1346                    )?;
1347
1348                    // Create backup branch name with timestamp
1349                    let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1350                    let backup_branch_name = format!("{target_branch}_backup_{timestamp}");
1351
1352                    warn!(
1353                        "⚠️  Force push to '{}' would overwrite {} commits on remote",
1354                        target_branch, commits_to_lose
1355                    );
1356
1357                    // Check if we're in a non-interactive environment (CI/testing)
1358                    if std::env::var("CI").is_ok() || std::env::var("FORCE_PUSH_NO_CONFIRM").is_ok()
1359                    {
1360                        info!(
1361                            "Non-interactive environment detected, proceeding with backup creation"
1362                        );
1363                        return Ok(Some(ForceBackupInfo {
1364                            backup_branch_name,
1365                            remote_commit_id: remote.id().to_string(),
1366                            commits_that_would_be_lost: commits_to_lose,
1367                        }));
1368                    }
1369
1370                    // Interactive confirmation
1371                    println!("\n⚠️  FORCE PUSH WARNING ⚠️");
1372                    println!("Force push to '{target_branch}' would overwrite {commits_to_lose} commits on remote:");
1373
1374                    // Show the commits that would be lost
1375                    match self
1376                        .get_commits_between(&merge_base_oid.to_string(), &remote.id().to_string())
1377                    {
1378                        Ok(commits) => {
1379                            println!("\nCommits that would be lost:");
1380                            for (i, commit) in commits.iter().take(5).enumerate() {
1381                                let short_hash = &commit.id().to_string()[..8];
1382                                let summary = commit.summary().unwrap_or("<no message>");
1383                                println!("  {}. {} - {}", i + 1, short_hash, summary);
1384                            }
1385                            if commits.len() > 5 {
1386                                println!("  ... and {} more commits", commits.len() - 5);
1387                            }
1388                        }
1389                        Err(_) => {
1390                            println!("  (Unable to retrieve commit details)");
1391                        }
1392                    }
1393
1394                    println!("\nA backup branch '{backup_branch_name}' will be created before proceeding.");
1395
1396                    let confirmed = Confirm::with_theme(&ColorfulTheme::default())
1397                        .with_prompt("Do you want to proceed with the force push?")
1398                        .default(false)
1399                        .interact()
1400                        .map_err(|e| {
1401                            CascadeError::config(format!("Failed to get user confirmation: {e}"))
1402                        })?;
1403
1404                    if !confirmed {
1405                        return Err(CascadeError::config(
1406                            "Force push cancelled by user. Use --force to bypass this check."
1407                                .to_string(),
1408                        ));
1409                    }
1410
1411                    return Ok(Some(ForceBackupInfo {
1412                        backup_branch_name,
1413                        remote_commit_id: remote.id().to_string(),
1414                        commits_that_would_be_lost: commits_to_lose,
1415                    }));
1416                }
1417            }
1418        }
1419
1420        Ok(None)
1421    }
1422
1423    /// Create a backup branch pointing to the remote commit that would be lost
1424    fn create_backup_branch(&self, original_branch: &str, remote_commit_id: &str) -> Result<()> {
1425        let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1426        let backup_branch_name = format!("{original_branch}_backup_{timestamp}");
1427
1428        // Parse the commit ID
1429        let commit_oid = Oid::from_str(remote_commit_id).map_err(|e| {
1430            CascadeError::config(format!("Invalid commit ID {remote_commit_id}: {e}"))
1431        })?;
1432
1433        // Find the commit
1434        let commit = self.repo.find_commit(commit_oid).map_err(|e| {
1435            CascadeError::config(format!("Failed to find commit {remote_commit_id}: {e}"))
1436        })?;
1437
1438        // Create the backup branch
1439        self.repo
1440            .branch(&backup_branch_name, &commit, false)
1441            .map_err(|e| {
1442                CascadeError::config(format!(
1443                    "Failed to create backup branch {backup_branch_name}: {e}"
1444                ))
1445            })?;
1446
1447        info!(
1448            "✅ Created backup branch '{}' pointing to {}",
1449            backup_branch_name,
1450            &remote_commit_id[..8]
1451        );
1452        Ok(())
1453    }
1454
1455    /// Check if branch deletion is safe by detecting unpushed commits
1456    /// Returns safety info if there are concerns that need user attention
1457    fn check_branch_deletion_safety(
1458        &self,
1459        branch_name: &str,
1460    ) -> Result<Option<BranchDeletionSafety>> {
1461        // First, try to fetch latest remote changes
1462        match self.fetch() {
1463            Ok(_) => {}
1464            Err(e) => {
1465                warn!(
1466                    "Could not fetch latest changes for branch deletion safety check: {}",
1467                    e
1468                );
1469            }
1470        }
1471
1472        // Find the branch
1473        let branch = self
1474            .repo
1475            .find_branch(branch_name, git2::BranchType::Local)
1476            .map_err(|e| {
1477                CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
1478            })?;
1479
1480        let _branch_commit = branch.get().peel_to_commit().map_err(|e| {
1481            CascadeError::branch(format!(
1482                "Could not get commit for branch '{branch_name}': {e}"
1483            ))
1484        })?;
1485
1486        // Determine the main branch (try common names)
1487        let main_branch_name = self.detect_main_branch()?;
1488
1489        // Check if branch is merged to main
1490        let is_merged_to_main = self.is_branch_merged_to_main(branch_name, &main_branch_name)?;
1491
1492        // Find the upstream/remote tracking branch
1493        let remote_tracking_branch = self.get_remote_tracking_branch(branch_name);
1494
1495        let mut unpushed_commits = Vec::new();
1496
1497        // Check for unpushed commits compared to remote tracking branch
1498        if let Some(ref remote_branch) = remote_tracking_branch {
1499            match self.get_commits_between(remote_branch, branch_name) {
1500                Ok(commits) => {
1501                    unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1502                }
1503                Err(_) => {
1504                    // If we can't compare with remote, check against main branch
1505                    if !is_merged_to_main {
1506                        if let Ok(commits) =
1507                            self.get_commits_between(&main_branch_name, branch_name)
1508                        {
1509                            unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1510                        }
1511                    }
1512                }
1513            }
1514        } else if !is_merged_to_main {
1515            // No remote tracking branch, check against main
1516            if let Ok(commits) = self.get_commits_between(&main_branch_name, branch_name) {
1517                unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1518            }
1519        }
1520
1521        // If there are concerns, return safety info
1522        if !unpushed_commits.is_empty() || (!is_merged_to_main && remote_tracking_branch.is_none())
1523        {
1524            Ok(Some(BranchDeletionSafety {
1525                unpushed_commits,
1526                remote_tracking_branch,
1527                is_merged_to_main,
1528                main_branch_name,
1529            }))
1530        } else {
1531            Ok(None)
1532        }
1533    }
1534
1535    /// Handle user confirmation for branch deletion with safety concerns
1536    fn handle_branch_deletion_confirmation(
1537        &self,
1538        branch_name: &str,
1539        safety_info: &BranchDeletionSafety,
1540    ) -> Result<()> {
1541        // Check if we're in a non-interactive environment
1542        if std::env::var("CI").is_ok() || std::env::var("BRANCH_DELETE_NO_CONFIRM").is_ok() {
1543            return Err(CascadeError::branch(
1544                format!(
1545                    "Branch '{branch_name}' has {} unpushed commits and cannot be deleted in non-interactive mode. Use --force to override.",
1546                    safety_info.unpushed_commits.len()
1547                )
1548            ));
1549        }
1550
1551        // Interactive warning and confirmation
1552        println!("\n⚠️  BRANCH DELETION WARNING ⚠️");
1553        println!("Branch '{branch_name}' has potential issues:");
1554
1555        if !safety_info.unpushed_commits.is_empty() {
1556            println!(
1557                "\n🔍 Unpushed commits ({} total):",
1558                safety_info.unpushed_commits.len()
1559            );
1560
1561            // Show details of unpushed commits
1562            for (i, commit_id) in safety_info.unpushed_commits.iter().take(5).enumerate() {
1563                if let Ok(commit) = self.repo.find_commit(Oid::from_str(commit_id).unwrap()) {
1564                    let short_hash = &commit_id[..8];
1565                    let summary = commit.summary().unwrap_or("<no message>");
1566                    println!("  {}. {} - {}", i + 1, short_hash, summary);
1567                }
1568            }
1569
1570            if safety_info.unpushed_commits.len() > 5 {
1571                println!(
1572                    "  ... and {} more commits",
1573                    safety_info.unpushed_commits.len() - 5
1574                );
1575            }
1576        }
1577
1578        if !safety_info.is_merged_to_main {
1579            println!("\n📋 Branch status:");
1580            println!("  • Not merged to '{}'", safety_info.main_branch_name);
1581            if let Some(ref remote) = safety_info.remote_tracking_branch {
1582                println!("  • Remote tracking branch: {remote}");
1583            } else {
1584                println!("  • No remote tracking branch");
1585            }
1586        }
1587
1588        println!("\n💡 Safer alternatives:");
1589        if !safety_info.unpushed_commits.is_empty() {
1590            if let Some(ref _remote) = safety_info.remote_tracking_branch {
1591                println!("  • Push commits first: git push origin {branch_name}");
1592            } else {
1593                println!("  • Create and push to remote: git push -u origin {branch_name}");
1594            }
1595        }
1596        if !safety_info.is_merged_to_main {
1597            println!(
1598                "  • Merge to {} first: git checkout {} && git merge {branch_name}",
1599                safety_info.main_branch_name, safety_info.main_branch_name
1600            );
1601        }
1602
1603        let confirmed = Confirm::with_theme(&ColorfulTheme::default())
1604            .with_prompt("Do you want to proceed with deleting this branch?")
1605            .default(false)
1606            .interact()
1607            .map_err(|e| CascadeError::branch(format!("Failed to get user confirmation: {e}")))?;
1608
1609        if !confirmed {
1610            return Err(CascadeError::branch(
1611                "Branch deletion cancelled by user. Use --force to bypass this check.".to_string(),
1612            ));
1613        }
1614
1615        Ok(())
1616    }
1617
1618    /// Detect the main branch name (main, master, develop)
1619    fn detect_main_branch(&self) -> Result<String> {
1620        let main_candidates = ["main", "master", "develop", "trunk"];
1621
1622        for candidate in &main_candidates {
1623            if self
1624                .repo
1625                .find_branch(candidate, git2::BranchType::Local)
1626                .is_ok()
1627            {
1628                return Ok(candidate.to_string());
1629            }
1630        }
1631
1632        // Fallback to HEAD's target if it's a symbolic reference
1633        if let Ok(head) = self.repo.head() {
1634            if let Some(name) = head.shorthand() {
1635                return Ok(name.to_string());
1636            }
1637        }
1638
1639        // Final fallback
1640        Ok("main".to_string())
1641    }
1642
1643    /// Check if a branch is merged to the main branch
1644    fn is_branch_merged_to_main(&self, branch_name: &str, main_branch: &str) -> Result<bool> {
1645        // Get the commits between main and the branch
1646        match self.get_commits_between(main_branch, branch_name) {
1647            Ok(commits) => Ok(commits.is_empty()),
1648            Err(_) => {
1649                // If we can't determine, assume not merged for safety
1650                Ok(false)
1651            }
1652        }
1653    }
1654
1655    /// Get the remote tracking branch for a local branch
1656    fn get_remote_tracking_branch(&self, branch_name: &str) -> Option<String> {
1657        // Try common remote tracking branch patterns
1658        let remote_candidates = [
1659            format!("origin/{branch_name}"),
1660            format!("remotes/origin/{branch_name}"),
1661        ];
1662
1663        for candidate in &remote_candidates {
1664            if self
1665                .repo
1666                .find_reference(&format!(
1667                    "refs/remotes/{}",
1668                    candidate.replace("remotes/", "")
1669                ))
1670                .is_ok()
1671            {
1672                return Some(candidate.clone());
1673            }
1674        }
1675
1676        None
1677    }
1678
1679    /// Check if checkout operation is safe
1680    fn check_checkout_safety(&self, _target: &str) -> Result<Option<CheckoutSafety>> {
1681        // Check if there are uncommitted changes
1682        let is_dirty = self.is_dirty()?;
1683        if !is_dirty {
1684            // No uncommitted changes, checkout is safe
1685            return Ok(None);
1686        }
1687
1688        // Get current branch for context
1689        let current_branch = self.get_current_branch().ok();
1690
1691        // Get detailed information about uncommitted changes
1692        let modified_files = self.get_modified_files()?;
1693        let staged_files = self.get_staged_files()?;
1694        let untracked_files = self.get_untracked_files()?;
1695
1696        let has_uncommitted_changes = !modified_files.is_empty() || !staged_files.is_empty();
1697
1698        if has_uncommitted_changes || !untracked_files.is_empty() {
1699            return Ok(Some(CheckoutSafety {
1700                has_uncommitted_changes,
1701                modified_files,
1702                staged_files,
1703                untracked_files,
1704                stash_created: None,
1705                current_branch,
1706            }));
1707        }
1708
1709        Ok(None)
1710    }
1711
1712    /// Handle user confirmation for checkout operations with uncommitted changes
1713    fn handle_checkout_confirmation(
1714        &self,
1715        target: &str,
1716        safety_info: &CheckoutSafety,
1717    ) -> Result<()> {
1718        // Check if we're in a non-interactive environment FIRST (before any output)
1719        let is_ci = std::env::var("CI").is_ok();
1720        let no_confirm = std::env::var("CHECKOUT_NO_CONFIRM").is_ok();
1721        let is_non_interactive = is_ci || no_confirm;
1722
1723        if is_non_interactive {
1724            return Err(CascadeError::branch(
1725                format!(
1726                    "Cannot checkout '{target}' with uncommitted changes in non-interactive mode. Commit your changes or use stash first."
1727                )
1728            ));
1729        }
1730
1731        // Interactive warning and confirmation
1732        println!("\n⚠️  CHECKOUT WARNING ⚠️");
1733        println!("You have uncommitted changes that could be lost:");
1734
1735        if !safety_info.modified_files.is_empty() {
1736            println!(
1737                "\n📝 Modified files ({}):",
1738                safety_info.modified_files.len()
1739            );
1740            for file in safety_info.modified_files.iter().take(10) {
1741                println!("   - {file}");
1742            }
1743            if safety_info.modified_files.len() > 10 {
1744                println!("   ... and {} more", safety_info.modified_files.len() - 10);
1745            }
1746        }
1747
1748        if !safety_info.staged_files.is_empty() {
1749            println!("\n📁 Staged files ({}):", safety_info.staged_files.len());
1750            for file in safety_info.staged_files.iter().take(10) {
1751                println!("   - {file}");
1752            }
1753            if safety_info.staged_files.len() > 10 {
1754                println!("   ... and {} more", safety_info.staged_files.len() - 10);
1755            }
1756        }
1757
1758        if !safety_info.untracked_files.is_empty() {
1759            println!(
1760                "\n❓ Untracked files ({}):",
1761                safety_info.untracked_files.len()
1762            );
1763            for file in safety_info.untracked_files.iter().take(5) {
1764                println!("   - {file}");
1765            }
1766            if safety_info.untracked_files.len() > 5 {
1767                println!("   ... and {} more", safety_info.untracked_files.len() - 5);
1768            }
1769        }
1770
1771        println!("\n🔄 Options:");
1772        println!("1. Stash changes and checkout (recommended)");
1773        println!("2. Force checkout (WILL LOSE UNCOMMITTED CHANGES)");
1774        println!("3. Cancel checkout");
1775
1776        let confirmation = Confirm::with_theme(&ColorfulTheme::default())
1777            .with_prompt("Would you like to stash your changes and proceed with checkout?")
1778            .interact()
1779            .map_err(|e| CascadeError::branch(format!("Could not get user confirmation: {e}")))?;
1780
1781        if confirmation {
1782            // Create stash before checkout
1783            let stash_message = format!(
1784                "Auto-stash before checkout to {} at {}",
1785                target,
1786                chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
1787            );
1788
1789            match self.create_stash(&stash_message) {
1790                Ok(stash_oid) => {
1791                    println!("✅ Created stash: {stash_message} ({stash_oid})");
1792                    println!("💡 You can restore with: git stash pop");
1793                }
1794                Err(e) => {
1795                    println!("❌ Failed to create stash: {e}");
1796
1797                    let force_confirm = Confirm::with_theme(&ColorfulTheme::default())
1798                        .with_prompt("Stash failed. Force checkout anyway? (WILL LOSE CHANGES)")
1799                        .interact()
1800                        .map_err(|e| {
1801                            CascadeError::branch(format!("Could not get confirmation: {e}"))
1802                        })?;
1803
1804                    if !force_confirm {
1805                        return Err(CascadeError::branch(
1806                            "Checkout cancelled by user".to_string(),
1807                        ));
1808                    }
1809                }
1810            }
1811        } else {
1812            return Err(CascadeError::branch(
1813                "Checkout cancelled by user".to_string(),
1814            ));
1815        }
1816
1817        Ok(())
1818    }
1819
1820    /// Create a stash with uncommitted changes
1821    fn create_stash(&self, message: &str) -> Result<String> {
1822        // For now, we'll use a different approach that doesn't require mutable access
1823        // This is a simplified version that recommends manual stashing
1824
1825        warn!("Automatic stashing not yet implemented - please stash manually");
1826        Err(CascadeError::branch(format!(
1827            "Please manually stash your changes first: git stash push -m \"{message}\""
1828        )))
1829    }
1830
1831    /// Get modified files in working directory
1832    fn get_modified_files(&self) -> Result<Vec<String>> {
1833        let mut opts = git2::StatusOptions::new();
1834        opts.include_untracked(false).include_ignored(false);
1835
1836        let statuses = self
1837            .repo
1838            .statuses(Some(&mut opts))
1839            .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
1840
1841        let mut modified_files = Vec::new();
1842        for status in statuses.iter() {
1843            let flags = status.status();
1844            if flags.contains(git2::Status::WT_MODIFIED) || flags.contains(git2::Status::WT_DELETED)
1845            {
1846                if let Some(path) = status.path() {
1847                    modified_files.push(path.to_string());
1848                }
1849            }
1850        }
1851
1852        Ok(modified_files)
1853    }
1854
1855    /// Get staged files in index
1856    fn get_staged_files(&self) -> Result<Vec<String>> {
1857        let mut opts = git2::StatusOptions::new();
1858        opts.include_untracked(false).include_ignored(false);
1859
1860        let statuses = self
1861            .repo
1862            .statuses(Some(&mut opts))
1863            .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
1864
1865        let mut staged_files = Vec::new();
1866        for status in statuses.iter() {
1867            let flags = status.status();
1868            if flags.contains(git2::Status::INDEX_MODIFIED)
1869                || flags.contains(git2::Status::INDEX_NEW)
1870                || flags.contains(git2::Status::INDEX_DELETED)
1871            {
1872                if let Some(path) = status.path() {
1873                    staged_files.push(path.to_string());
1874                }
1875            }
1876        }
1877
1878        Ok(staged_files)
1879    }
1880
1881    /// Count commits between two references
1882    fn count_commits_between(&self, from: &str, to: &str) -> Result<usize> {
1883        let commits = self.get_commits_between(from, to)?;
1884        Ok(commits.len())
1885    }
1886
1887    /// Resolve a reference (branch name, tag, or commit hash) to a commit
1888    pub fn resolve_reference(&self, reference: &str) -> Result<git2::Commit<'_>> {
1889        // Try to parse as commit hash first
1890        if let Ok(oid) = Oid::from_str(reference) {
1891            if let Ok(commit) = self.repo.find_commit(oid) {
1892                return Ok(commit);
1893            }
1894        }
1895
1896        // Try to resolve as a reference (branch, tag, etc.)
1897        let obj = self.repo.revparse_single(reference).map_err(|e| {
1898            CascadeError::branch(format!("Could not resolve reference '{reference}': {e}"))
1899        })?;
1900
1901        obj.peel_to_commit().map_err(|e| {
1902            CascadeError::branch(format!(
1903                "Reference '{reference}' does not point to a commit: {e}"
1904            ))
1905        })
1906    }
1907
1908    /// Reset HEAD to a specific reference (soft reset)
1909    pub fn reset_soft(&self, target_ref: &str) -> Result<()> {
1910        let target_commit = self.resolve_reference(target_ref)?;
1911
1912        self.repo
1913            .reset(target_commit.as_object(), git2::ResetType::Soft, None)
1914            .map_err(CascadeError::Git)?;
1915
1916        Ok(())
1917    }
1918
1919    /// Find which branch contains a specific commit
1920    pub fn find_branch_containing_commit(&self, commit_hash: &str) -> Result<String> {
1921        let oid = Oid::from_str(commit_hash).map_err(|e| {
1922            CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
1923        })?;
1924
1925        // Get all local branches
1926        let branches = self
1927            .repo
1928            .branches(Some(git2::BranchType::Local))
1929            .map_err(CascadeError::Git)?;
1930
1931        for branch_result in branches {
1932            let (branch, _) = branch_result.map_err(CascadeError::Git)?;
1933
1934            if let Some(branch_name) = branch.name().map_err(CascadeError::Git)? {
1935                // Check if this branch contains the commit
1936                if let Ok(branch_head) = branch.get().peel_to_commit() {
1937                    // Walk the commit history from this branch's HEAD
1938                    let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
1939                    revwalk.push(branch_head.id()).map_err(CascadeError::Git)?;
1940
1941                    for commit_oid in revwalk {
1942                        let commit_oid = commit_oid.map_err(CascadeError::Git)?;
1943                        if commit_oid == oid {
1944                            return Ok(branch_name.to_string());
1945                        }
1946                    }
1947                }
1948            }
1949        }
1950
1951        // If not found in any branch, might be on current HEAD
1952        Err(CascadeError::branch(format!(
1953            "Commit {commit_hash} not found in any local branch"
1954        )))
1955    }
1956
1957    // Async wrappers for potentially blocking operations
1958
1959    /// Fetch from remote origin (async)
1960    pub async fn fetch_async(&self) -> Result<()> {
1961        let repo_path = self.path.clone();
1962        crate::utils::async_ops::run_git_operation(move || {
1963            let repo = GitRepository::open(&repo_path)?;
1964            repo.fetch()
1965        })
1966        .await
1967    }
1968
1969    /// Pull changes from remote (async)
1970    pub async fn pull_async(&self, branch: &str) -> Result<()> {
1971        let repo_path = self.path.clone();
1972        let branch_name = branch.to_string();
1973        crate::utils::async_ops::run_git_operation(move || {
1974            let repo = GitRepository::open(&repo_path)?;
1975            repo.pull(&branch_name)
1976        })
1977        .await
1978    }
1979
1980    /// Push branch to remote (async)
1981    pub async fn push_branch_async(&self, branch_name: &str) -> Result<()> {
1982        let repo_path = self.path.clone();
1983        let branch = branch_name.to_string();
1984        crate::utils::async_ops::run_git_operation(move || {
1985            let repo = GitRepository::open(&repo_path)?;
1986            repo.push(&branch)
1987        })
1988        .await
1989    }
1990
1991    /// Cherry-pick commit (async)
1992    pub async fn cherry_pick_commit_async(&self, commit_hash: &str) -> Result<String> {
1993        let repo_path = self.path.clone();
1994        let hash = commit_hash.to_string();
1995        crate::utils::async_ops::run_git_operation(move || {
1996            let repo = GitRepository::open(&repo_path)?;
1997            repo.cherry_pick(&hash)
1998        })
1999        .await
2000    }
2001
2002    /// Get commit hashes between two refs (async)
2003    pub async fn get_commit_hashes_between_async(
2004        &self,
2005        from: &str,
2006        to: &str,
2007    ) -> Result<Vec<String>> {
2008        let repo_path = self.path.clone();
2009        let from_str = from.to_string();
2010        let to_str = to.to_string();
2011        crate::utils::async_ops::run_git_operation(move || {
2012            let repo = GitRepository::open(&repo_path)?;
2013            let commits = repo.get_commits_between(&from_str, &to_str)?;
2014            Ok(commits.into_iter().map(|c| c.id().to_string()).collect())
2015        })
2016        .await
2017    }
2018
2019    /// Reset a branch to point to a specific commit
2020    pub fn reset_branch_to_commit(&self, branch_name: &str, commit_hash: &str) -> Result<()> {
2021        info!(
2022            "Resetting branch '{}' to commit {}",
2023            branch_name,
2024            &commit_hash[..8]
2025        );
2026
2027        // Find the target commit
2028        let target_oid = git2::Oid::from_str(commit_hash).map_err(|e| {
2029            CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
2030        })?;
2031
2032        let _target_commit = self.repo.find_commit(target_oid).map_err(|e| {
2033            CascadeError::branch(format!("Could not find commit '{commit_hash}': {e}"))
2034        })?;
2035
2036        // Find the branch
2037        let _branch = self
2038            .repo
2039            .find_branch(branch_name, git2::BranchType::Local)
2040            .map_err(|e| {
2041                CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
2042            })?;
2043
2044        // Update the branch reference to point to the target commit
2045        let branch_ref_name = format!("refs/heads/{branch_name}");
2046        self.repo
2047            .reference(
2048                &branch_ref_name,
2049                target_oid,
2050                true,
2051                &format!("Reset {branch_name} to {commit_hash}"),
2052            )
2053            .map_err(|e| {
2054                CascadeError::branch(format!(
2055                    "Could not reset branch '{branch_name}' to commit '{commit_hash}': {e}"
2056                ))
2057            })?;
2058
2059        tracing::info!(
2060            "Successfully reset branch '{}' to commit {}",
2061            branch_name,
2062            &commit_hash[..8]
2063        );
2064        Ok(())
2065    }
2066}
2067
2068#[cfg(test)]
2069mod tests {
2070    use super::*;
2071    use std::process::Command;
2072    use tempfile::TempDir;
2073
2074    fn create_test_repo() -> (TempDir, PathBuf) {
2075        let temp_dir = TempDir::new().unwrap();
2076        let repo_path = temp_dir.path().to_path_buf();
2077
2078        // Initialize git repository
2079        Command::new("git")
2080            .args(["init"])
2081            .current_dir(&repo_path)
2082            .output()
2083            .unwrap();
2084        Command::new("git")
2085            .args(["config", "user.name", "Test"])
2086            .current_dir(&repo_path)
2087            .output()
2088            .unwrap();
2089        Command::new("git")
2090            .args(["config", "user.email", "test@test.com"])
2091            .current_dir(&repo_path)
2092            .output()
2093            .unwrap();
2094
2095        // Create initial commit
2096        std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
2097        Command::new("git")
2098            .args(["add", "."])
2099            .current_dir(&repo_path)
2100            .output()
2101            .unwrap();
2102        Command::new("git")
2103            .args(["commit", "-m", "Initial commit"])
2104            .current_dir(&repo_path)
2105            .output()
2106            .unwrap();
2107
2108        (temp_dir, repo_path)
2109    }
2110
2111    fn create_commit(repo_path: &PathBuf, message: &str, filename: &str) {
2112        let file_path = repo_path.join(filename);
2113        std::fs::write(&file_path, format!("Content for {filename}\n")).unwrap();
2114
2115        Command::new("git")
2116            .args(["add", filename])
2117            .current_dir(repo_path)
2118            .output()
2119            .unwrap();
2120        Command::new("git")
2121            .args(["commit", "-m", message])
2122            .current_dir(repo_path)
2123            .output()
2124            .unwrap();
2125    }
2126
2127    #[test]
2128    fn test_repository_info() {
2129        let (_temp_dir, repo_path) = create_test_repo();
2130        let repo = GitRepository::open(&repo_path).unwrap();
2131
2132        let info = repo.get_info().unwrap();
2133        assert!(!info.is_dirty); // Should be clean after commit
2134        assert!(
2135            info.head_branch == Some("master".to_string())
2136                || info.head_branch == Some("main".to_string()),
2137            "Expected default branch to be 'master' or 'main', got {:?}",
2138            info.head_branch
2139        );
2140        assert!(info.head_commit.is_some()); // Just check it exists
2141        assert!(info.untracked_files.is_empty()); // Should be empty after commit
2142    }
2143
2144    #[test]
2145    fn test_force_push_branch_basic() {
2146        let (_temp_dir, repo_path) = create_test_repo();
2147        let repo = GitRepository::open(&repo_path).unwrap();
2148
2149        // Get the actual default branch name
2150        let default_branch = repo.get_current_branch().unwrap();
2151
2152        // Create source branch with commits
2153        create_commit(&repo_path, "Feature commit 1", "feature1.rs");
2154        Command::new("git")
2155            .args(["checkout", "-b", "source-branch"])
2156            .current_dir(&repo_path)
2157            .output()
2158            .unwrap();
2159        create_commit(&repo_path, "Feature commit 2", "feature2.rs");
2160
2161        // Create target branch
2162        Command::new("git")
2163            .args(["checkout", &default_branch])
2164            .current_dir(&repo_path)
2165            .output()
2166            .unwrap();
2167        Command::new("git")
2168            .args(["checkout", "-b", "target-branch"])
2169            .current_dir(&repo_path)
2170            .output()
2171            .unwrap();
2172        create_commit(&repo_path, "Target commit", "target.rs");
2173
2174        // Test force push from source to target
2175        let result = repo.force_push_branch("target-branch", "source-branch");
2176
2177        // Should succeed in test environment (even though it doesn't actually push to remote)
2178        // The important thing is that the function doesn't panic and handles the git2 operations
2179        assert!(result.is_ok() || result.is_err()); // Either is acceptable for unit test
2180    }
2181
2182    #[test]
2183    fn test_force_push_branch_nonexistent_branches() {
2184        let (_temp_dir, repo_path) = create_test_repo();
2185        let repo = GitRepository::open(&repo_path).unwrap();
2186
2187        // Get the actual default branch name
2188        let default_branch = repo.get_current_branch().unwrap();
2189
2190        // Test force push with nonexistent source branch
2191        let result = repo.force_push_branch("target", "nonexistent-source");
2192        assert!(result.is_err());
2193
2194        // Test force push with nonexistent target branch
2195        let result = repo.force_push_branch("nonexistent-target", &default_branch);
2196        assert!(result.is_err());
2197    }
2198
2199    #[test]
2200    fn test_force_push_workflow_simulation() {
2201        let (_temp_dir, repo_path) = create_test_repo();
2202        let repo = GitRepository::open(&repo_path).unwrap();
2203
2204        // Simulate the smart force push workflow:
2205        // 1. Original branch exists with PR
2206        Command::new("git")
2207            .args(["checkout", "-b", "feature-auth"])
2208            .current_dir(&repo_path)
2209            .output()
2210            .unwrap();
2211        create_commit(&repo_path, "Add authentication", "auth.rs");
2212
2213        // 2. Rebase creates versioned branch
2214        Command::new("git")
2215            .args(["checkout", "-b", "feature-auth-v2"])
2216            .current_dir(&repo_path)
2217            .output()
2218            .unwrap();
2219        create_commit(&repo_path, "Fix auth validation", "auth.rs");
2220
2221        // 3. Smart force push: update original branch from versioned branch
2222        let result = repo.force_push_branch("feature-auth", "feature-auth-v2");
2223
2224        // Verify the operation is handled properly (success or expected error)
2225        match result {
2226            Ok(_) => {
2227                // Force push succeeded - verify branch state if possible
2228                Command::new("git")
2229                    .args(["checkout", "feature-auth"])
2230                    .current_dir(&repo_path)
2231                    .output()
2232                    .unwrap();
2233                let log_output = Command::new("git")
2234                    .args(["log", "--oneline", "-2"])
2235                    .current_dir(&repo_path)
2236                    .output()
2237                    .unwrap();
2238                let log_str = String::from_utf8_lossy(&log_output.stdout);
2239                assert!(
2240                    log_str.contains("Fix auth validation")
2241                        || log_str.contains("Add authentication")
2242                );
2243            }
2244            Err(_) => {
2245                // Expected in test environment without remote - that's fine
2246                // The important thing is we tested the code path without panicking
2247            }
2248        }
2249    }
2250
2251    #[test]
2252    fn test_branch_operations() {
2253        let (_temp_dir, repo_path) = create_test_repo();
2254        let repo = GitRepository::open(&repo_path).unwrap();
2255
2256        // Test get current branch - accept either main or master
2257        let current = repo.get_current_branch().unwrap();
2258        assert!(
2259            current == "master" || current == "main",
2260            "Expected default branch to be 'master' or 'main', got '{current}'"
2261        );
2262
2263        // Test create branch
2264        Command::new("git")
2265            .args(["checkout", "-b", "test-branch"])
2266            .current_dir(&repo_path)
2267            .output()
2268            .unwrap();
2269        let current = repo.get_current_branch().unwrap();
2270        assert_eq!(current, "test-branch");
2271    }
2272
2273    #[test]
2274    fn test_commit_operations() {
2275        let (_temp_dir, repo_path) = create_test_repo();
2276        let repo = GitRepository::open(&repo_path).unwrap();
2277
2278        // Test get head commit
2279        let head = repo.get_head_commit().unwrap();
2280        assert_eq!(head.message().unwrap().trim(), "Initial commit");
2281
2282        // Test get commit by hash
2283        let hash = head.id().to_string();
2284        let same_commit = repo.get_commit(&hash).unwrap();
2285        assert_eq!(head.id(), same_commit.id());
2286    }
2287
2288    #[test]
2289    fn test_checkout_safety_clean_repo() {
2290        let (_temp_dir, repo_path) = create_test_repo();
2291        let repo = GitRepository::open(&repo_path).unwrap();
2292
2293        // Create a test branch
2294        create_commit(&repo_path, "Second commit", "test.txt");
2295        Command::new("git")
2296            .args(["checkout", "-b", "test-branch"])
2297            .current_dir(&repo_path)
2298            .output()
2299            .unwrap();
2300
2301        // Test checkout safety with clean repo
2302        let safety_result = repo.check_checkout_safety("main");
2303        assert!(safety_result.is_ok());
2304        assert!(safety_result.unwrap().is_none()); // Clean repo should return None
2305    }
2306
2307    #[test]
2308    fn test_checkout_safety_with_modified_files() {
2309        let (_temp_dir, repo_path) = create_test_repo();
2310        let repo = GitRepository::open(&repo_path).unwrap();
2311
2312        // Create a test branch
2313        Command::new("git")
2314            .args(["checkout", "-b", "test-branch"])
2315            .current_dir(&repo_path)
2316            .output()
2317            .unwrap();
2318
2319        // Modify a file to create uncommitted changes
2320        std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2321
2322        // Test checkout safety with modified files
2323        let safety_result = repo.check_checkout_safety("main");
2324        assert!(safety_result.is_ok());
2325        let safety_info = safety_result.unwrap();
2326        assert!(safety_info.is_some());
2327
2328        let info = safety_info.unwrap();
2329        assert!(!info.modified_files.is_empty());
2330        assert!(info.modified_files.contains(&"README.md".to_string()));
2331    }
2332
2333    #[test]
2334    fn test_unsafe_checkout_methods() {
2335        let (_temp_dir, repo_path) = create_test_repo();
2336        let repo = GitRepository::open(&repo_path).unwrap();
2337
2338        // Create a test branch
2339        create_commit(&repo_path, "Second commit", "test.txt");
2340        Command::new("git")
2341            .args(["checkout", "-b", "test-branch"])
2342            .current_dir(&repo_path)
2343            .output()
2344            .unwrap();
2345
2346        // Modify a file to create uncommitted changes
2347        std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2348
2349        // Test unsafe checkout methods bypass safety checks
2350        let _result = repo.checkout_branch_unsafe("master");
2351        // Note: This might still fail due to git2 restrictions, but shouldn't hit our safety code
2352        // The important thing is that it doesn't trigger our safety confirmation
2353
2354        // Test unsafe commit checkout
2355        let head_commit = repo.get_head_commit().unwrap();
2356        let commit_hash = head_commit.id().to_string();
2357        let _result = repo.checkout_commit_unsafe(&commit_hash);
2358        // Similar to above - testing that safety is bypassed
2359    }
2360
2361    #[test]
2362    fn test_get_modified_files() {
2363        let (_temp_dir, repo_path) = create_test_repo();
2364        let repo = GitRepository::open(&repo_path).unwrap();
2365
2366        // Initially should have no modified files
2367        let modified = repo.get_modified_files().unwrap();
2368        assert!(modified.is_empty());
2369
2370        // Modify a file
2371        std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2372
2373        // Should now detect the modified file
2374        let modified = repo.get_modified_files().unwrap();
2375        assert_eq!(modified.len(), 1);
2376        assert!(modified.contains(&"README.md".to_string()));
2377    }
2378
2379    #[test]
2380    fn test_get_staged_files() {
2381        let (_temp_dir, repo_path) = create_test_repo();
2382        let repo = GitRepository::open(&repo_path).unwrap();
2383
2384        // Initially should have no staged files
2385        let staged = repo.get_staged_files().unwrap();
2386        assert!(staged.is_empty());
2387
2388        // Create and stage a new file
2389        std::fs::write(repo_path.join("staged.txt"), "Staged content").unwrap();
2390        Command::new("git")
2391            .args(["add", "staged.txt"])
2392            .current_dir(&repo_path)
2393            .output()
2394            .unwrap();
2395
2396        // Should now detect the staged file
2397        let staged = repo.get_staged_files().unwrap();
2398        assert_eq!(staged.len(), 1);
2399        assert!(staged.contains(&"staged.txt".to_string()));
2400    }
2401
2402    #[test]
2403    fn test_create_stash_fallback() {
2404        let (_temp_dir, repo_path) = create_test_repo();
2405        let repo = GitRepository::open(&repo_path).unwrap();
2406
2407        // Test that stash creation returns helpful error message
2408        let result = repo.create_stash("test stash");
2409        assert!(result.is_err());
2410        let error_msg = result.unwrap_err().to_string();
2411        assert!(error_msg.contains("git stash push"));
2412    }
2413
2414    #[test]
2415    fn test_delete_branch_unsafe() {
2416        let (_temp_dir, repo_path) = create_test_repo();
2417        let repo = GitRepository::open(&repo_path).unwrap();
2418
2419        // Create a test branch
2420        create_commit(&repo_path, "Second commit", "test.txt");
2421        Command::new("git")
2422            .args(["checkout", "-b", "test-branch"])
2423            .current_dir(&repo_path)
2424            .output()
2425            .unwrap();
2426
2427        // Add another commit to the test branch to make it different from master
2428        create_commit(&repo_path, "Branch-specific commit", "branch.txt");
2429
2430        // Go back to master
2431        Command::new("git")
2432            .args(["checkout", "master"])
2433            .current_dir(&repo_path)
2434            .output()
2435            .unwrap();
2436
2437        // Test unsafe delete bypasses safety checks
2438        // Note: This may still fail if the branch has unpushed commits, but it should bypass our safety confirmation
2439        let result = repo.delete_branch_unsafe("test-branch");
2440        // Even if it fails, the key is that it didn't prompt for user confirmation
2441        // So we just check that it attempted the operation without interactive prompts
2442        let _ = result; // Don't assert success since delete may fail for git reasons
2443    }
2444
2445    #[test]
2446    fn test_force_push_unsafe() {
2447        let (_temp_dir, repo_path) = create_test_repo();
2448        let repo = GitRepository::open(&repo_path).unwrap();
2449
2450        // Create a test branch
2451        create_commit(&repo_path, "Second commit", "test.txt");
2452        Command::new("git")
2453            .args(["checkout", "-b", "test-branch"])
2454            .current_dir(&repo_path)
2455            .output()
2456            .unwrap();
2457
2458        // Test unsafe force push bypasses safety checks
2459        // Note: This will likely fail due to no remote, but it tests the safety bypass
2460        let _result = repo.force_push_branch_unsafe("test-branch", "test-branch");
2461        // The key is that it doesn't trigger safety confirmation dialogs
2462    }
2463}