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