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> {
227 let statuses = self.repo.statuses(None).map_err(CascadeError::Git)?;
228
229 for status in statuses.iter() {
230 let flags = status.status();
231
232 if let Some(path) = status.path() {
234 if path.starts_with(".cascade/") || path == ".cascade" {
235 continue;
236 }
237 }
238
239 if flags.intersects(
241 git2::Status::INDEX_MODIFIED
242 | git2::Status::INDEX_NEW
243 | git2::Status::INDEX_DELETED
244 | git2::Status::WT_MODIFIED
245 | git2::Status::WT_NEW
246 | git2::Status::WT_DELETED,
247 ) {
248 return Ok(true);
249 }
250 }
251
252 Ok(false)
253 }
254
255 pub fn get_untracked_files(&self) -> Result<Vec<String>> {
257 let statuses = self.repo.statuses(None).map_err(CascadeError::Git)?;
258
259 let mut untracked = Vec::new();
260 for status in statuses.iter() {
261 if status.status().contains(git2::Status::WT_NEW) {
262 if let Some(path) = status.path() {
263 untracked.push(path.to_string());
264 }
265 }
266 }
267
268 Ok(untracked)
269 }
270
271 pub fn create_branch(&self, name: &str, target: Option<&str>) -> Result<()> {
273 let target_commit = if let Some(target) = target {
274 let target_obj = self.repo.revparse_single(target).map_err(|e| {
276 CascadeError::branch(format!("Could not find target '{target}': {e}"))
277 })?;
278 target_obj.peel_to_commit().map_err(|e| {
279 CascadeError::branch(format!("Target '{target}' is not a commit: {e}"))
280 })?
281 } else {
282 let head = self
284 .repo
285 .head()
286 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
287 head.peel_to_commit()
288 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))?
289 };
290
291 self.repo
292 .branch(name, &target_commit, false)
293 .map_err(|e| CascadeError::branch(format!("Could not create branch '{name}': {e}")))?;
294
295 Ok(())
297 }
298
299 pub fn update_branch_to_commit(&self, branch_name: &str, commit_id: &str) -> Result<()> {
302 let commit_oid = Oid::from_str(commit_id).map_err(|e| {
303 CascadeError::branch(format!("Invalid commit ID '{}': {}", commit_id, e))
304 })?;
305
306 let commit = self.repo.find_commit(commit_oid).map_err(|e| {
307 CascadeError::branch(format!("Commit '{}' not found: {}", commit_id, e))
308 })?;
309
310 if self
312 .repo
313 .find_branch(branch_name, git2::BranchType::Local)
314 .is_ok()
315 {
316 let refname = format!("refs/heads/{}", branch_name);
318 self.repo
319 .reference(
320 &refname,
321 commit_oid,
322 true,
323 "update branch to rebased commit",
324 )
325 .map_err(|e| {
326 CascadeError::branch(format!(
327 "Failed to update branch '{}': {}",
328 branch_name, e
329 ))
330 })?;
331 } else {
332 self.repo.branch(branch_name, &commit, false).map_err(|e| {
334 CascadeError::branch(format!("Failed to create branch '{}': {}", branch_name, e))
335 })?;
336 }
337
338 Ok(())
339 }
340
341 pub fn force_push_single_branch(&self, branch_name: &str) -> Result<()> {
343 self.force_push_single_branch_with_options(branch_name, false, false)
344 }
345
346 pub fn force_push_single_branch_auto(&self, branch_name: &str) -> Result<()> {
348 self.force_push_single_branch_with_options(branch_name, true, false)
349 }
350
351 pub fn force_push_single_branch_auto_no_fetch(&self, branch_name: &str) -> Result<()> {
354 self.force_push_single_branch_with_options(branch_name, true, true)
355 }
356
357 fn force_push_single_branch_with_options(
358 &self,
359 branch_name: &str,
360 auto_confirm: bool,
361 skip_fetch: bool,
362 ) -> Result<()> {
363 if self.get_branch_commit_hash(branch_name).is_err() {
366 return Err(CascadeError::branch(format!(
367 "Cannot push '{}': branch does not exist locally",
368 branch_name
369 )));
370 }
371
372 if !skip_fetch {
376 self.fetch_with_retry()?;
377 }
378
379 let safety_result = if auto_confirm {
381 self.check_force_push_safety_auto_no_fetch(branch_name)?
382 } else {
383 self.check_force_push_safety_enhanced(branch_name)?
384 };
385
386 if let Some(backup_info) = safety_result {
387 self.create_backup_branch(branch_name, &backup_info.remote_commit_id)?;
388 Output::sub_item(format!(
389 "Created backup branch: {}",
390 backup_info.backup_branch_name
391 ));
392 }
393
394 self.ensure_index_closed()?;
396
397 let marker_path = self.path.join(".git").join(".cascade-internal-push");
400 std::fs::write(&marker_path, "1")
401 .map_err(|e| CascadeError::branch(format!("Failed to create push marker: {}", e)))?;
402
403 let output = std::process::Command::new("git")
405 .args(["push", "--force", "origin", branch_name])
406 .current_dir(&self.path)
407 .output()
408 .map_err(|e| {
409 let _ = std::fs::remove_file(&marker_path);
411 CascadeError::branch(format!("Failed to execute git push: {}", e))
412 })?;
413
414 let _ = std::fs::remove_file(&marker_path);
416
417 if !output.status.success() {
418 let stderr = String::from_utf8_lossy(&output.stderr);
419 let stdout = String::from_utf8_lossy(&output.stdout);
420
421 let full_error = if !stdout.is_empty() {
423 format!("{}\n{}", stderr.trim(), stdout.trim())
424 } else {
425 stderr.trim().to_string()
426 };
427
428 return Err(CascadeError::branch(format!(
429 "Force push failed for '{}':\n{}",
430 branch_name, full_error
431 )));
432 }
433
434 Ok(())
435 }
436
437 pub fn checkout_branch(&self, name: &str) -> Result<()> {
439 self.checkout_branch_with_options(name, false, true)
440 }
441
442 pub fn checkout_branch_silent(&self, name: &str) -> Result<()> {
444 self.checkout_branch_with_options(name, false, false)
445 }
446
447 pub fn checkout_branch_unsafe(&self, name: &str) -> Result<()> {
449 self.checkout_branch_with_options(name, true, false)
450 }
451
452 fn checkout_branch_with_options(
454 &self,
455 name: &str,
456 force_unsafe: bool,
457 show_output: bool,
458 ) -> Result<()> {
459 debug!("Attempting to checkout branch: {}", name);
460
461 if !force_unsafe {
463 let safety_result = self.check_checkout_safety(name)?;
464 if let Some(safety_info) = safety_result {
465 self.handle_checkout_confirmation(name, &safety_info)?;
467 }
468 }
469
470 let branch = self
472 .repo
473 .find_branch(name, git2::BranchType::Local)
474 .map_err(|e| CascadeError::branch(format!("Could not find branch '{name}': {e}")))?;
475
476 let branch_ref = branch.get();
477 let tree = branch_ref.peel_to_tree().map_err(|e| {
478 CascadeError::branch(format!("Could not get tree for branch '{name}': {e}"))
479 })?;
480
481 let mut checkout_builder = git2::build::CheckoutBuilder::new();
484 checkout_builder.force(); checkout_builder.remove_untracked(false); self.repo
488 .checkout_tree(tree.as_object(), Some(&mut checkout_builder))
489 .map_err(|e| {
490 CascadeError::branch(format!("Could not checkout branch '{name}': {e}"))
491 })?;
492
493 self.repo
495 .set_head(&format!("refs/heads/{name}"))
496 .map_err(|e| CascadeError::branch(format!("Could not update HEAD to '{name}': {e}")))?;
497
498 if show_output {
499 Output::success(format!("Switched to branch '{name}'"));
500 }
501 Ok(())
502 }
503
504 pub fn checkout_commit(&self, commit_hash: &str) -> Result<()> {
506 self.checkout_commit_with_options(commit_hash, false)
507 }
508
509 pub fn checkout_commit_unsafe(&self, commit_hash: &str) -> Result<()> {
511 self.checkout_commit_with_options(commit_hash, true)
512 }
513
514 fn checkout_commit_with_options(&self, commit_hash: &str, force_unsafe: bool) -> Result<()> {
516 debug!("Attempting to checkout commit: {}", commit_hash);
517
518 if !force_unsafe {
520 let safety_result = self.check_checkout_safety(&format!("commit:{commit_hash}"))?;
521 if let Some(safety_info) = safety_result {
522 self.handle_checkout_confirmation(&format!("commit {commit_hash}"), &safety_info)?;
524 }
525 }
526
527 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
528
529 let commit = self.repo.find_commit(oid).map_err(|e| {
530 CascadeError::branch(format!("Could not find commit '{commit_hash}': {e}"))
531 })?;
532
533 let tree = commit.tree().map_err(|e| {
534 CascadeError::branch(format!(
535 "Could not get tree for commit '{commit_hash}': {e}"
536 ))
537 })?;
538
539 self.repo
541 .checkout_tree(tree.as_object(), None)
542 .map_err(|e| {
543 CascadeError::branch(format!("Could not checkout commit '{commit_hash}': {e}"))
544 })?;
545
546 self.repo.set_head_detached(oid).map_err(|e| {
548 CascadeError::branch(format!(
549 "Could not update HEAD to commit '{commit_hash}': {e}"
550 ))
551 })?;
552
553 Output::success(format!(
554 "Checked out commit '{commit_hash}' (detached HEAD)"
555 ));
556 Ok(())
557 }
558
559 pub fn branch_exists(&self, name: &str) -> bool {
561 self.repo.find_branch(name, git2::BranchType::Local).is_ok()
562 }
563
564 pub fn branch_exists_or_fetch(&self, name: &str) -> Result<bool> {
566 if self.repo.find_branch(name, git2::BranchType::Local).is_ok() {
568 return Ok(true);
569 }
570
571 crate::cli::output::Output::info(format!(
573 "Branch '{name}' not found locally, trying to fetch from remote..."
574 ));
575
576 use std::process::Command;
577
578 let fetch_result = Command::new("git")
580 .args(["fetch", "origin", &format!("{name}:{name}")])
581 .current_dir(&self.path)
582 .output();
583
584 match fetch_result {
585 Ok(output) => {
586 if output.status.success() {
587 println!("✅ Successfully fetched '{name}' from origin");
588 return Ok(self.repo.find_branch(name, git2::BranchType::Local).is_ok());
590 } else {
591 let stderr = String::from_utf8_lossy(&output.stderr);
592 tracing::debug!("Failed to fetch branch '{name}': {stderr}");
593 }
594 }
595 Err(e) => {
596 tracing::debug!("Git fetch command failed: {e}");
597 }
598 }
599
600 if name.contains('/') {
602 crate::cli::output::Output::info("Trying alternative fetch patterns...");
603
604 let fetch_all_result = Command::new("git")
606 .args(["fetch", "origin"])
607 .current_dir(&self.path)
608 .output();
609
610 if let Ok(output) = fetch_all_result {
611 if output.status.success() {
612 let checkout_result = Command::new("git")
614 .args(["checkout", "-b", name, &format!("origin/{name}")])
615 .current_dir(&self.path)
616 .output();
617
618 if let Ok(checkout_output) = checkout_result {
619 if checkout_output.status.success() {
620 println!(
621 "✅ Successfully created local branch '{name}' from origin/{name}"
622 );
623 return Ok(true);
624 }
625 }
626 }
627 }
628 }
629
630 Ok(false)
632 }
633
634 pub fn get_branch_commit_hash(&self, branch_name: &str) -> Result<String> {
636 let branch = self
637 .repo
638 .find_branch(branch_name, git2::BranchType::Local)
639 .map_err(|e| {
640 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
641 })?;
642
643 let commit = branch.get().peel_to_commit().map_err(|e| {
644 CascadeError::branch(format!(
645 "Could not get commit for branch '{branch_name}': {e}"
646 ))
647 })?;
648
649 Ok(commit.id().to_string())
650 }
651
652 pub fn list_branches(&self) -> Result<Vec<String>> {
654 let branches = self
655 .repo
656 .branches(Some(git2::BranchType::Local))
657 .map_err(CascadeError::Git)?;
658
659 let mut branch_names = Vec::new();
660 for branch in branches {
661 let (branch, _) = branch.map_err(CascadeError::Git)?;
662 if let Some(name) = branch.name().map_err(CascadeError::Git)? {
663 branch_names.push(name.to_string());
664 }
665 }
666
667 Ok(branch_names)
668 }
669
670 pub fn get_upstream_branch(&self, branch_name: &str) -> Result<Option<String>> {
672 let config = self.repo.config().map_err(CascadeError::Git)?;
674
675 let remote_key = format!("branch.{branch_name}.remote");
677 let merge_key = format!("branch.{branch_name}.merge");
678
679 if let (Ok(remote), Ok(merge_ref)) = (
680 config.get_string(&remote_key),
681 config.get_string(&merge_key),
682 ) {
683 if let Some(branch_part) = merge_ref.strip_prefix("refs/heads/") {
685 return Ok(Some(format!("{remote}/{branch_part}")));
686 }
687 }
688
689 let potential_upstream = format!("origin/{branch_name}");
691 if self
692 .repo
693 .find_reference(&format!("refs/remotes/{potential_upstream}"))
694 .is_ok()
695 {
696 return Ok(Some(potential_upstream));
697 }
698
699 Ok(None)
700 }
701
702 pub fn get_ahead_behind_counts(
704 &self,
705 local_branch: &str,
706 upstream_branch: &str,
707 ) -> Result<(usize, usize)> {
708 let local_ref = self
710 .repo
711 .find_reference(&format!("refs/heads/{local_branch}"))
712 .map_err(|_| {
713 CascadeError::config(format!("Local branch '{local_branch}' not found"))
714 })?;
715 let local_commit = local_ref.peel_to_commit().map_err(CascadeError::Git)?;
716
717 let upstream_ref = self
718 .repo
719 .find_reference(&format!("refs/remotes/{upstream_branch}"))
720 .map_err(|_| {
721 CascadeError::config(format!("Upstream branch '{upstream_branch}' not found"))
722 })?;
723 let upstream_commit = upstream_ref.peel_to_commit().map_err(CascadeError::Git)?;
724
725 let (ahead, behind) = self
727 .repo
728 .graph_ahead_behind(local_commit.id(), upstream_commit.id())
729 .map_err(CascadeError::Git)?;
730
731 Ok((ahead, behind))
732 }
733
734 pub fn set_upstream(&self, branch_name: &str, remote: &str, remote_branch: &str) -> Result<()> {
736 let mut config = self.repo.config().map_err(CascadeError::Git)?;
737
738 let remote_key = format!("branch.{branch_name}.remote");
740 config
741 .set_str(&remote_key, remote)
742 .map_err(CascadeError::Git)?;
743
744 let merge_key = format!("branch.{branch_name}.merge");
746 let merge_value = format!("refs/heads/{remote_branch}");
747 config
748 .set_str(&merge_key, &merge_value)
749 .map_err(CascadeError::Git)?;
750
751 Ok(())
752 }
753
754 pub fn commit(&self, message: &str) -> Result<String> {
756 self.validate_git_user_config()?;
758
759 let signature = self.get_signature()?;
760 let tree_id = self.get_index_tree()?;
761 let tree = self.repo.find_tree(tree_id).map_err(CascadeError::Git)?;
762
763 let head = self.repo.head().map_err(CascadeError::Git)?;
765 let parent_commit = head.peel_to_commit().map_err(CascadeError::Git)?;
766
767 let commit_id = self
768 .repo
769 .commit(
770 Some("HEAD"),
771 &signature,
772 &signature,
773 message,
774 &tree,
775 &[&parent_commit],
776 )
777 .map_err(CascadeError::Git)?;
778
779 Output::success(format!("Created commit: {commit_id} - {message}"));
780 Ok(commit_id.to_string())
781 }
782
783 pub fn commit_staged_changes(&self, default_message: &str) -> Result<Option<String>> {
785 let staged_files = self.get_staged_files()?;
787 if staged_files.is_empty() {
788 tracing::debug!("No staged changes to commit");
789 return Ok(None);
790 }
791
792 tracing::debug!("Committing {} staged files", staged_files.len());
793 let commit_hash = self.commit(default_message)?;
794 Ok(Some(commit_hash))
795 }
796
797 pub fn stage_all(&self) -> Result<()> {
799 let mut index = self.repo.index().map_err(CascadeError::Git)?;
800
801 index
802 .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
803 .map_err(CascadeError::Git)?;
804
805 index.write().map_err(CascadeError::Git)?;
806 drop(index); tracing::debug!("Staged all changes");
809 Ok(())
810 }
811
812 fn ensure_index_closed(&self) -> Result<()> {
815 let mut index = self.repo.index().map_err(CascadeError::Git)?;
818 index.write().map_err(CascadeError::Git)?;
819 drop(index); std::thread::sleep(std::time::Duration::from_millis(10));
825
826 Ok(())
827 }
828
829 pub fn stage_files(&self, file_paths: &[&str]) -> Result<()> {
831 if file_paths.is_empty() {
832 tracing::debug!("No files to stage");
833 return Ok(());
834 }
835
836 let mut index = self.repo.index().map_err(CascadeError::Git)?;
837
838 for file_path in file_paths {
839 index
840 .add_path(std::path::Path::new(file_path))
841 .map_err(CascadeError::Git)?;
842 }
843
844 index.write().map_err(CascadeError::Git)?;
845 drop(index); tracing::debug!(
848 "Staged {} specific files: {:?}",
849 file_paths.len(),
850 file_paths
851 );
852 Ok(())
853 }
854
855 pub fn stage_conflict_resolved_files(&self) -> Result<()> {
857 let conflicted_files = self.get_conflicted_files()?;
858 if conflicted_files.is_empty() {
859 tracing::debug!("No conflicted files to stage");
860 return Ok(());
861 }
862
863 let file_paths: Vec<&str> = conflicted_files.iter().map(|s| s.as_str()).collect();
864 self.stage_files(&file_paths)?;
865
866 tracing::debug!("Staged {} conflict-resolved files", conflicted_files.len());
867 Ok(())
868 }
869
870 pub fn cleanup_state(&self) -> Result<()> {
872 let state = self.repo.state();
873 if state == git2::RepositoryState::Clean {
874 return Ok(());
875 }
876
877 tracing::debug!("Cleaning up repository state: {:?}", state);
878 self.repo.cleanup_state().map_err(|e| {
879 CascadeError::branch(format!(
880 "Failed to clean up repository state ({:?}): {}",
881 state, e
882 ))
883 })
884 }
885
886 pub fn path(&self) -> &Path {
888 &self.path
889 }
890
891 pub fn commit_exists(&self, commit_hash: &str) -> Result<bool> {
893 match Oid::from_str(commit_hash) {
894 Ok(oid) => match self.repo.find_commit(oid) {
895 Ok(_) => Ok(true),
896 Err(_) => Ok(false),
897 },
898 Err(_) => Ok(false),
899 }
900 }
901
902 pub fn is_commit_based_on(&self, commit_hash: &str, expected_base: &str) -> Result<bool> {
905 let commit_oid = Oid::from_str(commit_hash).map_err(|e| {
906 CascadeError::branch(format!("Invalid commit hash '{}': {}", commit_hash, e))
907 })?;
908
909 let commit = self.repo.find_commit(commit_oid).map_err(|e| {
910 CascadeError::branch(format!("Commit '{}' not found: {}", commit_hash, e))
911 })?;
912
913 if commit.parent_count() == 0 {
915 return Ok(false);
917 }
918
919 let parent = commit.parent(0).map_err(|e| {
920 CascadeError::branch(format!(
921 "Could not get parent of commit '{}': {}",
922 commit_hash, e
923 ))
924 })?;
925 let parent_hash = parent.id().to_string();
926
927 let expected_base_oid = if let Ok(oid) = Oid::from_str(expected_base) {
929 oid
930 } else {
931 let branch_ref = format!("refs/heads/{}", expected_base);
933 let reference = self.repo.find_reference(&branch_ref).map_err(|e| {
934 CascadeError::branch(format!("Could not find base '{}': {}", expected_base, e))
935 })?;
936 reference.target().ok_or_else(|| {
937 CascadeError::branch(format!("Base '{}' has no target commit", expected_base))
938 })?
939 };
940
941 let expected_base_hash = expected_base_oid.to_string();
942
943 tracing::debug!(
944 "Checking if commit {} is based on {}: parent={}, expected={}",
945 &commit_hash[..8],
946 expected_base,
947 &parent_hash[..8],
948 &expected_base_hash[..8]
949 );
950
951 Ok(parent_hash == expected_base_hash)
952 }
953
954 pub fn is_descendant_of(&self, descendant: &str, ancestor: &str) -> Result<bool> {
956 let descendant_oid = Oid::from_str(descendant).map_err(|e| {
957 CascadeError::branch(format!(
958 "Invalid commit hash '{}' for descendant check: {}",
959 descendant, e
960 ))
961 })?;
962 let ancestor_oid = Oid::from_str(ancestor).map_err(|e| {
963 CascadeError::branch(format!(
964 "Invalid commit hash '{}' for descendant check: {}",
965 ancestor, e
966 ))
967 })?;
968
969 self.repo
970 .graph_descendant_of(descendant_oid, ancestor_oid)
971 .map_err(CascadeError::Git)
972 }
973
974 pub fn get_head_commit(&self) -> Result<git2::Commit<'_>> {
976 let head = self
977 .repo
978 .head()
979 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
980 head.peel_to_commit()
981 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))
982 }
983
984 pub fn get_commit(&self, commit_hash: &str) -> Result<git2::Commit<'_>> {
986 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
987
988 self.repo.find_commit(oid).map_err(CascadeError::Git)
989 }
990
991 pub fn get_branch_head(&self, branch_name: &str) -> Result<String> {
993 let branch = self
994 .repo
995 .find_branch(branch_name, git2::BranchType::Local)
996 .map_err(|e| {
997 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
998 })?;
999
1000 let commit = branch.get().peel_to_commit().map_err(|e| {
1001 CascadeError::branch(format!(
1002 "Could not get commit for branch '{branch_name}': {e}"
1003 ))
1004 })?;
1005
1006 Ok(commit.id().to_string())
1007 }
1008
1009 pub fn get_remote_branch_head(&self, branch_name: &str) -> Result<String> {
1011 let refname = format!("refs/remotes/origin/{branch_name}");
1012 let reference = self.repo.find_reference(&refname).map_err(|e| {
1013 CascadeError::branch(format!("Remote branch '{branch_name}' not found: {e}"))
1014 })?;
1015
1016 let target = reference.target().ok_or_else(|| {
1017 CascadeError::branch(format!(
1018 "Remote branch '{branch_name}' does not have a target commit"
1019 ))
1020 })?;
1021
1022 Ok(target.to_string())
1023 }
1024
1025 pub fn validate_git_user_config(&self) -> Result<()> {
1027 if let Ok(config) = self.repo.config() {
1028 let name_result = config.get_string("user.name");
1029 let email_result = config.get_string("user.email");
1030
1031 if let (Ok(name), Ok(email)) = (name_result, email_result) {
1032 if !name.trim().is_empty() && !email.trim().is_empty() {
1033 tracing::debug!("Git user config validated: {} <{}>", name, email);
1034 return Ok(());
1035 }
1036 }
1037 }
1038
1039 let is_ci = std::env::var("CI").is_ok();
1041
1042 if is_ci {
1043 tracing::debug!("CI environment - skipping git user config validation");
1044 return Ok(());
1045 }
1046
1047 Output::warning("Git user configuration missing or incomplete");
1048 Output::info("This can cause cherry-pick and commit operations to fail");
1049 Output::info("Please configure git user information:");
1050 Output::bullet("git config user.name \"Your Name\"".to_string());
1051 Output::bullet("git config user.email \"your.email@example.com\"".to_string());
1052 Output::info("Or set globally with the --global flag");
1053
1054 Ok(())
1057 }
1058
1059 pub fn get_user_info(&self) -> (Option<String>, Option<String>) {
1061 let config = match self.repo.config() {
1062 Ok(c) => c,
1063 Err(_) => return (None, None),
1064 };
1065 let name = config.get_string("user.name").ok();
1066 let email = config.get_string("user.email").ok();
1067 (name, email)
1068 }
1069
1070 fn get_signature(&self) -> Result<Signature<'_>> {
1072 if let Ok(config) = self.repo.config() {
1074 let name_result = config.get_string("user.name");
1076 let email_result = config.get_string("user.email");
1077
1078 if let (Ok(name), Ok(email)) = (name_result, email_result) {
1079 if !name.trim().is_empty() && !email.trim().is_empty() {
1080 tracing::debug!("Using git config: {} <{}>", name, email);
1081 return Signature::now(&name, &email).map_err(CascadeError::Git);
1082 }
1083 } else {
1084 tracing::debug!("Git user config incomplete or missing");
1085 }
1086 }
1087
1088 let is_ci = std::env::var("CI").is_ok();
1090
1091 if is_ci {
1092 tracing::debug!("CI environment detected, using fallback signature");
1093 return Signature::now("Cascade CLI", "cascade@example.com").map_err(CascadeError::Git);
1094 }
1095
1096 tracing::warn!("Git user configuration missing - this can cause commit operations to fail");
1098
1099 match Signature::now("Cascade CLI", "cascade@example.com") {
1101 Ok(sig) => {
1102 Output::warning("Git user not configured - using fallback signature");
1103 Output::info("For better git history, run:");
1104 Output::bullet("git config user.name \"Your Name\"".to_string());
1105 Output::bullet("git config user.email \"your.email@example.com\"".to_string());
1106 Output::info("Or set it globally with --global flag");
1107 Ok(sig)
1108 }
1109 Err(e) => {
1110 Err(CascadeError::branch(format!(
1111 "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\""
1112 )))
1113 }
1114 }
1115 }
1116
1117 fn configure_remote_callbacks(&self) -> Result<git2::RemoteCallbacks<'_>> {
1120 self.configure_remote_callbacks_with_fallback(false)
1121 }
1122
1123 fn should_retry_with_default_credentials(&self, error: &git2::Error) -> bool {
1125 match error.class() {
1126 git2::ErrorClass::Http => {
1128 match error.code() {
1130 git2::ErrorCode::Auth => true,
1131 _ => {
1132 let error_string = error.to_string();
1134 error_string.contains("too many redirects")
1135 || error_string.contains("authentication replays")
1136 || error_string.contains("authentication required")
1137 }
1138 }
1139 }
1140 git2::ErrorClass::Net => {
1141 let error_string = error.to_string();
1143 error_string.contains("authentication")
1144 || error_string.contains("unauthorized")
1145 || error_string.contains("forbidden")
1146 }
1147 _ => false,
1148 }
1149 }
1150
1151 fn should_fallback_to_git_cli(&self, error: &git2::Error) -> bool {
1153 match error.class() {
1154 git2::ErrorClass::Ssl => true,
1156
1157 git2::ErrorClass::Http if error.code() == git2::ErrorCode::Certificate => true,
1159
1160 git2::ErrorClass::Ssh => {
1162 let error_string = error.to_string();
1163 error_string.contains("no callback set")
1164 || error_string.contains("authentication required")
1165 }
1166
1167 git2::ErrorClass::Net => {
1169 let error_string = error.to_string();
1170 error_string.contains("TLS stream")
1171 || error_string.contains("SSL")
1172 || error_string.contains("proxy")
1173 || error_string.contains("firewall")
1174 }
1175
1176 git2::ErrorClass::Http => {
1178 let error_string = error.to_string();
1179 error_string.contains("TLS stream")
1180 || error_string.contains("SSL")
1181 || error_string.contains("proxy")
1182 }
1183
1184 _ => false,
1185 }
1186 }
1187
1188 fn configure_remote_callbacks_with_fallback(
1189 &self,
1190 use_default_first: bool,
1191 ) -> Result<git2::RemoteCallbacks<'_>> {
1192 let mut callbacks = git2::RemoteCallbacks::new();
1193
1194 let bitbucket_credentials = self.bitbucket_credentials.clone();
1196 callbacks.credentials(move |url, username_from_url, allowed_types| {
1197 tracing::debug!(
1198 "Authentication requested for URL: {}, username: {:?}, allowed_types: {:?}",
1199 url,
1200 username_from_url,
1201 allowed_types
1202 );
1203
1204 if allowed_types.contains(git2::CredentialType::SSH_KEY) {
1206 if let Some(username) = username_from_url {
1207 tracing::debug!("Trying SSH key authentication for user: {}", username);
1208 return git2::Cred::ssh_key_from_agent(username);
1209 }
1210 }
1211
1212 if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
1214 if use_default_first {
1216 tracing::debug!("Corporate network mode: trying DefaultCredentials first");
1217 return git2::Cred::default();
1218 }
1219
1220 if url.contains("bitbucket") {
1221 if let Some(creds) = &bitbucket_credentials {
1222 if let (Some(username), Some(token)) = (&creds.username, &creds.token) {
1224 tracing::debug!("Trying Bitbucket username + token authentication");
1225 return git2::Cred::userpass_plaintext(username, token);
1226 }
1227
1228 if let Some(token) = &creds.token {
1230 tracing::debug!("Trying Bitbucket token-as-username authentication");
1231 return git2::Cred::userpass_plaintext(token, "");
1232 }
1233
1234 if let Some(username) = &creds.username {
1236 tracing::debug!("Trying Bitbucket username authentication (will use credential helper)");
1237 return git2::Cred::username(username);
1238 }
1239 }
1240 }
1241
1242 tracing::debug!("Trying default credential helper for HTTPS authentication");
1244 return git2::Cred::default();
1245 }
1246
1247 tracing::debug!("Using default credential fallback");
1249 git2::Cred::default()
1250 });
1251
1252 let mut ssl_configured = false;
1257
1258 if let Some(ssl_config) = &self.ssl_config {
1260 if ssl_config.accept_invalid_certs {
1261 Output::warning(
1262 "SSL certificate verification DISABLED via Cascade config - this is insecure!",
1263 );
1264 callbacks.certificate_check(|_cert, _host| {
1265 tracing::debug!("⚠️ Accepting invalid certificate for host: {}", _host);
1266 Ok(git2::CertificateCheckStatus::CertificateOk)
1267 });
1268 ssl_configured = true;
1269 } else if let Some(ca_path) = &ssl_config.ca_bundle_path {
1270 Output::info(format!(
1271 "Using custom CA bundle from Cascade config: {ca_path}"
1272 ));
1273 callbacks.certificate_check(|_cert, host| {
1274 tracing::debug!("Using custom CA bundle for host: {}", host);
1275 Ok(git2::CertificateCheckStatus::CertificateOk)
1276 });
1277 ssl_configured = true;
1278 }
1279 }
1280
1281 if !ssl_configured {
1283 if let Ok(config) = self.repo.config() {
1284 let ssl_verify = config.get_bool("http.sslVerify").unwrap_or(true);
1285
1286 if !ssl_verify {
1287 Output::warning(
1288 "SSL certificate verification DISABLED via git config - this is insecure!",
1289 );
1290 callbacks.certificate_check(|_cert, host| {
1291 tracing::debug!("⚠️ Bypassing SSL verification for host: {}", host);
1292 Ok(git2::CertificateCheckStatus::CertificateOk)
1293 });
1294 ssl_configured = true;
1295 } else if let Ok(ca_path) = config.get_string("http.sslCAInfo") {
1296 Output::info(format!("Using custom CA bundle from git config: {ca_path}"));
1297 callbacks.certificate_check(|_cert, host| {
1298 tracing::debug!("Using git config CA bundle for host: {}", host);
1299 Ok(git2::CertificateCheckStatus::CertificateOk)
1300 });
1301 ssl_configured = true;
1302 }
1303 }
1304 }
1305
1306 if !ssl_configured {
1309 tracing::debug!(
1310 "Using system certificate store for SSL verification (default behavior)"
1311 );
1312
1313 if cfg!(target_os = "macos") {
1315 tracing::debug!("macOS detected - using default certificate validation");
1316 } else {
1319 callbacks.certificate_check(|_cert, host| {
1321 tracing::debug!("System certificate validation for host: {}", host);
1322 Ok(git2::CertificateCheckStatus::CertificatePassthrough)
1323 });
1324 }
1325 }
1326
1327 Ok(callbacks)
1328 }
1329
1330 fn get_index_tree(&self) -> Result<Oid> {
1332 let mut index = self.repo.index().map_err(CascadeError::Git)?;
1333
1334 index.write_tree().map_err(CascadeError::Git)
1335 }
1336
1337 pub fn get_status(&self) -> Result<git2::Statuses<'_>> {
1339 self.repo.statuses(None).map_err(CascadeError::Git)
1340 }
1341
1342 pub fn get_status_summary(&self) -> Result<GitStatusSummary> {
1344 let statuses = self.get_status()?;
1345
1346 let mut staged_files = 0;
1347 let mut unstaged_files = 0;
1348 let mut untracked_files = 0;
1349
1350 for status in statuses.iter() {
1351 let flags = status.status();
1352
1353 if flags.intersects(
1354 git2::Status::INDEX_MODIFIED
1355 | git2::Status::INDEX_NEW
1356 | git2::Status::INDEX_DELETED
1357 | git2::Status::INDEX_RENAMED
1358 | git2::Status::INDEX_TYPECHANGE,
1359 ) {
1360 staged_files += 1;
1361 }
1362
1363 if flags.intersects(
1364 git2::Status::WT_MODIFIED
1365 | git2::Status::WT_DELETED
1366 | git2::Status::WT_TYPECHANGE
1367 | git2::Status::WT_RENAMED,
1368 ) {
1369 unstaged_files += 1;
1370 }
1371
1372 if flags.intersects(git2::Status::WT_NEW) {
1373 untracked_files += 1;
1374 }
1375 }
1376
1377 Ok(GitStatusSummary {
1378 staged_files,
1379 unstaged_files,
1380 untracked_files,
1381 })
1382 }
1383
1384 pub fn get_current_commit_hash(&self) -> Result<String> {
1386 self.get_head_commit_hash()
1387 }
1388
1389 pub fn get_commit_count_between(&self, from_commit: &str, to_commit: &str) -> Result<usize> {
1391 let from_oid = git2::Oid::from_str(from_commit).map_err(CascadeError::Git)?;
1392 let to_oid = git2::Oid::from_str(to_commit).map_err(CascadeError::Git)?;
1393
1394 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
1395 revwalk.push(to_oid).map_err(CascadeError::Git)?;
1396 revwalk.hide(from_oid).map_err(CascadeError::Git)?;
1397
1398 Ok(revwalk.count())
1399 }
1400
1401 pub fn get_remote_url(&self, name: &str) -> Result<String> {
1403 let remote = self.repo.find_remote(name).map_err(CascadeError::Git)?;
1404 Ok(remote.url().unwrap_or("unknown").to_string())
1405 }
1406
1407 pub fn cherry_pick(&self, commit_hash: &str) -> Result<String> {
1409 tracing::debug!("Cherry-picking commit {}", commit_hash);
1410
1411 self.validate_git_user_config()?;
1413
1414 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
1415 let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
1416
1417 let commit_tree = commit.tree().map_err(CascadeError::Git)?;
1419
1420 let parent_commit = if commit.parent_count() > 0 {
1422 commit.parent(0).map_err(CascadeError::Git)?
1423 } else {
1424 let empty_tree_oid = self.repo.treebuilder(None)?.write()?;
1426 let empty_tree = self.repo.find_tree(empty_tree_oid)?;
1427 let sig = self.get_signature()?;
1428 return self
1429 .repo
1430 .commit(
1431 Some("HEAD"),
1432 &sig,
1433 &sig,
1434 commit.message().unwrap_or("Cherry-picked commit"),
1435 &empty_tree,
1436 &[],
1437 )
1438 .map(|oid| oid.to_string())
1439 .map_err(CascadeError::Git);
1440 };
1441
1442 let parent_tree = parent_commit.tree().map_err(CascadeError::Git)?;
1443
1444 let head_commit = self.get_head_commit()?;
1446 let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
1447
1448 let mut index = self
1450 .repo
1451 .merge_trees(&parent_tree, &head_tree, &commit_tree, None)
1452 .map_err(CascadeError::Git)?;
1453
1454 if index.has_conflicts() {
1456 debug!("Cherry-pick has conflicts - writing conflicted state to disk for resolution");
1459
1460 let mut repo_index = self.repo.index().map_err(CascadeError::Git)?;
1466
1467 repo_index.clear().map_err(CascadeError::Git)?;
1469 repo_index
1470 .read_tree(&head_tree)
1471 .map_err(CascadeError::Git)?;
1472
1473 repo_index
1475 .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
1476 .map_err(CascadeError::Git)?;
1477
1478 drop(repo_index);
1483 self.ensure_index_closed()?;
1484
1485 let cherry_pick_output = std::process::Command::new("git")
1486 .args(["cherry-pick", commit_hash])
1487 .current_dir(self.path())
1488 .output()
1489 .map_err(CascadeError::Io)?;
1490
1491 if !cherry_pick_output.status.success() {
1492 debug!("Git CLI cherry-pick failed as expected (has conflicts)");
1493 }
1496
1497 self.repo
1500 .index()
1501 .and_then(|mut idx| idx.read(true).map(|_| ()))
1502 .map_err(CascadeError::Git)?;
1503
1504 debug!("Conflicted state written and index reloaded - auto-resolve can now process conflicts");
1505
1506 return Err(CascadeError::branch(format!(
1507 "Cherry-pick of {commit_hash} has conflicts that need manual resolution"
1508 )));
1509 }
1510
1511 let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
1513 let merged_tree = self
1514 .repo
1515 .find_tree(merged_tree_oid)
1516 .map_err(CascadeError::Git)?;
1517
1518 let signature = self.get_signature()?;
1520 let message = commit.message().unwrap_or("Cherry-picked commit");
1521
1522 let new_commit_oid = self
1523 .repo
1524 .commit(
1525 Some("HEAD"),
1526 &signature,
1527 &signature,
1528 message,
1529 &merged_tree,
1530 &[&head_commit],
1531 )
1532 .map_err(CascadeError::Git)?;
1533
1534 let new_commit = self
1536 .repo
1537 .find_commit(new_commit_oid)
1538 .map_err(CascadeError::Git)?;
1539 let new_tree = new_commit.tree().map_err(CascadeError::Git)?;
1540
1541 self.repo
1542 .checkout_tree(
1543 new_tree.as_object(),
1544 Some(git2::build::CheckoutBuilder::new().force()),
1545 )
1546 .map_err(CascadeError::Git)?;
1547
1548 tracing::debug!("Cherry-picked {} -> {}", commit_hash, new_commit_oid);
1549 Ok(new_commit_oid.to_string())
1550 }
1551
1552 pub fn has_conflicts(&self) -> Result<bool> {
1554 let index = self.repo.index().map_err(CascadeError::Git)?;
1555 Ok(index.has_conflicts())
1556 }
1557
1558 pub fn get_conflicted_files(&self) -> Result<Vec<String>> {
1560 let index = self.repo.index().map_err(CascadeError::Git)?;
1561
1562 let mut conflicts = Vec::new();
1563
1564 let conflict_iter = index.conflicts().map_err(CascadeError::Git)?;
1566
1567 for conflict in conflict_iter {
1568 let conflict = conflict.map_err(CascadeError::Git)?;
1569 if let Some(our) = conflict.our {
1570 if let Ok(path) = std::str::from_utf8(&our.path) {
1571 conflicts.push(path.to_string());
1572 }
1573 } else if let Some(their) = conflict.their {
1574 if let Ok(path) = std::str::from_utf8(&their.path) {
1575 conflicts.push(path.to_string());
1576 }
1577 }
1578 }
1579
1580 Ok(conflicts)
1581 }
1582
1583 pub fn fetch(&self) -> Result<()> {
1585 tracing::debug!("Fetching from origin");
1586
1587 self.ensure_index_closed()?;
1590
1591 let mut remote = self
1592 .repo
1593 .find_remote("origin")
1594 .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
1595
1596 let callbacks = self.configure_remote_callbacks()?;
1598
1599 let mut fetch_options = git2::FetchOptions::new();
1601 fetch_options.remote_callbacks(callbacks);
1602
1603 match remote.fetch::<&str>(&[], Some(&mut fetch_options), None) {
1605 Ok(_) => {
1606 tracing::debug!("Fetch completed successfully");
1607 Ok(())
1608 }
1609 Err(e) => {
1610 if self.should_retry_with_default_credentials(&e) {
1611 tracing::debug!(
1612 "Authentication error detected (class: {:?}, code: {:?}): {}, retrying with DefaultCredentials",
1613 e.class(), e.code(), e
1614 );
1615
1616 let callbacks = self.configure_remote_callbacks_with_fallback(true)?;
1618 let mut fetch_options = git2::FetchOptions::new();
1619 fetch_options.remote_callbacks(callbacks);
1620
1621 match remote.fetch::<&str>(&[], Some(&mut fetch_options), None) {
1622 Ok(_) => {
1623 tracing::debug!("Fetch succeeded with DefaultCredentials");
1624 return Ok(());
1625 }
1626 Err(retry_error) => {
1627 tracing::debug!(
1628 "DefaultCredentials retry failed: {}, falling back to git CLI",
1629 retry_error
1630 );
1631 return self.fetch_with_git_cli();
1632 }
1633 }
1634 }
1635
1636 if self.should_fallback_to_git_cli(&e) {
1637 tracing::debug!(
1638 "Network/SSL error detected (class: {:?}, code: {:?}): {}, falling back to git CLI for fetch operation",
1639 e.class(), e.code(), e
1640 );
1641 return self.fetch_with_git_cli();
1642 }
1643 Err(CascadeError::Git(e))
1644 }
1645 }
1646 }
1647
1648 pub fn fetch_with_retry(&self) -> Result<()> {
1651 const MAX_RETRIES: u32 = 3;
1652 const BASE_DELAY_MS: u64 = 500;
1653
1654 let mut last_error = None;
1655
1656 for attempt in 0..MAX_RETRIES {
1657 match self.fetch() {
1658 Ok(_) => return Ok(()),
1659 Err(e) => {
1660 last_error = Some(e);
1661
1662 if attempt < MAX_RETRIES - 1 {
1663 let delay_ms = BASE_DELAY_MS * 2_u64.pow(attempt);
1664 debug!(
1665 "Fetch attempt {} failed, retrying in {}ms...",
1666 attempt + 1,
1667 delay_ms
1668 );
1669 std::thread::sleep(std::time::Duration::from_millis(delay_ms));
1670 }
1671 }
1672 }
1673 }
1674
1675 Err(CascadeError::Git(git2::Error::from_str(&format!(
1677 "Critical: Failed to fetch remote refs after {} attempts. Cannot safely proceed with force push - \
1678 stale remote refs could cause data loss. Error: {}. Please check network connection.",
1679 MAX_RETRIES,
1680 last_error.unwrap()
1681 ))))
1682 }
1683
1684 pub fn pull(&self, branch: &str) -> Result<()> {
1686 tracing::debug!("Pulling branch: {}", branch);
1687
1688 match self.fetch() {
1690 Ok(_) => {}
1691 Err(e) => {
1692 let error_string = e.to_string();
1694 if error_string.contains("TLS stream") || error_string.contains("SSL") {
1695 tracing::warn!(
1696 "git2 error detected: {}, falling back to git CLI for pull operation",
1697 e
1698 );
1699 return self.pull_with_git_cli(branch);
1700 }
1701 return Err(e);
1702 }
1703 }
1704
1705 let remote_branch_name = format!("origin/{branch}");
1707 let remote_oid = self
1708 .repo
1709 .refname_to_id(&format!("refs/remotes/{remote_branch_name}"))
1710 .map_err(|e| {
1711 CascadeError::branch(format!("Remote branch {remote_branch_name} not found: {e}"))
1712 })?;
1713
1714 let remote_commit = self
1715 .repo
1716 .find_commit(remote_oid)
1717 .map_err(CascadeError::Git)?;
1718
1719 let head_commit = self.get_head_commit()?;
1721
1722 if head_commit.id() == remote_commit.id() {
1724 tracing::debug!("Already up to date");
1725 return Ok(());
1726 }
1727
1728 let merge_base_oid = self
1730 .repo
1731 .merge_base(head_commit.id(), remote_commit.id())
1732 .map_err(CascadeError::Git)?;
1733
1734 if merge_base_oid == head_commit.id() {
1735 tracing::debug!("Fast-forwarding {} to {}", branch, remote_commit.id());
1737
1738 let refname = format!("refs/heads/{}", branch);
1740 self.repo
1741 .reference(&refname, remote_oid, true, "pull: Fast-forward")
1742 .map_err(CascadeError::Git)?;
1743
1744 self.repo.set_head(&refname).map_err(CascadeError::Git)?;
1746
1747 self.repo
1749 .checkout_head(Some(
1750 git2::build::CheckoutBuilder::new()
1751 .force()
1752 .remove_untracked(false),
1753 ))
1754 .map_err(CascadeError::Git)?;
1755
1756 tracing::debug!("Fast-forwarded to {}", remote_commit.id());
1757 return Ok(());
1758 }
1759
1760 Err(CascadeError::branch(format!(
1763 "Branch '{}' has diverged from remote. Local has commits not in remote. \
1764 Protected branches should not have local commits. \
1765 Try: git reset --hard origin/{}",
1766 branch, branch
1767 )))
1768 }
1769
1770 pub fn push(&self, branch: &str) -> Result<()> {
1772 let mut remote = self
1775 .repo
1776 .find_remote("origin")
1777 .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
1778
1779 let remote_url = remote.url().unwrap_or("unknown").to_string();
1780 tracing::debug!("Remote URL: {}", remote_url);
1781
1782 let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
1783 tracing::debug!("Push refspec: {}", refspec);
1784
1785 let mut callbacks = self.configure_remote_callbacks()?;
1787
1788 callbacks.push_update_reference(|refname, status| {
1790 if let Some(msg) = status {
1791 tracing::debug!("Push failed for ref {}: {}", refname, msg);
1792 return Err(git2::Error::from_str(&format!("Push failed: {msg}")));
1793 }
1794 tracing::debug!("Push succeeded for ref: {}", refname);
1795 Ok(())
1796 });
1797
1798 let mut push_options = git2::PushOptions::new();
1800 push_options.remote_callbacks(callbacks);
1801
1802 match remote.push(&[&refspec], Some(&mut push_options)) {
1804 Ok(_) => {
1805 tracing::debug!("Push completed successfully for branch: {}", branch);
1806 Ok(())
1807 }
1808 Err(e) => {
1809 tracing::debug!(
1810 "git2 push error: {} (class: {:?}, code: {:?})",
1811 e,
1812 e.class(),
1813 e.code()
1814 );
1815
1816 if self.should_retry_with_default_credentials(&e) {
1817 tracing::debug!(
1818 "Authentication error detected (class: {:?}, code: {:?}): {}, retrying with DefaultCredentials",
1819 e.class(), e.code(), e
1820 );
1821
1822 let callbacks = self.configure_remote_callbacks_with_fallback(true)?;
1824 let mut push_options = git2::PushOptions::new();
1825 push_options.remote_callbacks(callbacks);
1826
1827 match remote.push(&[&refspec], Some(&mut push_options)) {
1828 Ok(_) => {
1829 tracing::debug!("Push succeeded with DefaultCredentials");
1830 return Ok(());
1831 }
1832 Err(retry_error) => {
1833 tracing::debug!(
1834 "DefaultCredentials retry failed: {}, falling back to git CLI",
1835 retry_error
1836 );
1837 return self.push_with_git_cli(branch);
1838 }
1839 }
1840 }
1841
1842 if self.should_fallback_to_git_cli(&e) {
1843 tracing::debug!(
1844 "Network/SSL error detected (class: {:?}, code: {:?}): {}, falling back to git CLI for push operation",
1845 e.class(), e.code(), e
1846 );
1847 return self.push_with_git_cli(branch);
1848 }
1849
1850 let error_msg = if e.to_string().contains("authentication") {
1852 format!(
1853 "Authentication failed for branch '{branch}'. Try: git push origin {branch}"
1854 )
1855 } else {
1856 format!("Failed to push branch '{branch}': {e}")
1857 };
1858
1859 Err(CascadeError::branch(error_msg))
1860 }
1861 }
1862 }
1863
1864 fn push_with_git_cli(&self, branch: &str) -> Result<()> {
1867 self.ensure_index_closed()?;
1869
1870 let output = std::process::Command::new("git")
1871 .args(["push", "origin", branch])
1872 .current_dir(&self.path)
1873 .output()
1874 .map_err(|e| CascadeError::branch(format!("Failed to execute git command: {e}")))?;
1875
1876 if output.status.success() {
1877 Ok(())
1879 } else {
1880 let stderr = String::from_utf8_lossy(&output.stderr);
1881 let _stdout = String::from_utf8_lossy(&output.stdout);
1882 let error_msg = if stderr.contains("SSL_connect") || stderr.contains("SSL_ERROR") {
1884 "Network error: Unable to connect to repository (VPN may be required)".to_string()
1885 } else if stderr.contains("repository") && stderr.contains("not found") {
1886 "Repository not found - check your Bitbucket configuration".to_string()
1887 } else if stderr.contains("authentication") || stderr.contains("403") {
1888 "Authentication failed - check your credentials".to_string()
1889 } else {
1890 stderr.trim().to_string()
1892 };
1893 Err(CascadeError::branch(error_msg))
1894 }
1895 }
1896
1897 fn fetch_with_git_cli(&self) -> Result<()> {
1900 tracing::debug!("Using git CLI fallback for fetch operation");
1901
1902 self.ensure_index_closed()?;
1904
1905 let output = std::process::Command::new("git")
1906 .args(["fetch", "origin"])
1907 .current_dir(&self.path)
1908 .output()
1909 .map_err(|e| {
1910 CascadeError::Git(git2::Error::from_str(&format!(
1911 "Failed to execute git command: {e}"
1912 )))
1913 })?;
1914
1915 if output.status.success() {
1916 tracing::debug!("Git CLI fetch succeeded");
1917 Ok(())
1918 } else {
1919 let stderr = String::from_utf8_lossy(&output.stderr);
1920 let stdout = String::from_utf8_lossy(&output.stdout);
1921 let error_msg = format!(
1922 "Git CLI fetch failed: {}\nStdout: {}\nStderr: {}",
1923 output.status, stdout, stderr
1924 );
1925 Err(CascadeError::Git(git2::Error::from_str(&error_msg)))
1926 }
1927 }
1928
1929 fn pull_with_git_cli(&self, branch: &str) -> Result<()> {
1932 tracing::debug!("Using git CLI fallback for pull operation: {}", branch);
1933
1934 self.ensure_index_closed()?;
1936
1937 let output = std::process::Command::new("git")
1938 .args(["pull", "origin", branch])
1939 .current_dir(&self.path)
1940 .output()
1941 .map_err(|e| {
1942 CascadeError::Git(git2::Error::from_str(&format!(
1943 "Failed to execute git command: {e}"
1944 )))
1945 })?;
1946
1947 if output.status.success() {
1948 tracing::debug!("Git CLI pull succeeded for branch: {}", branch);
1949 Ok(())
1950 } else {
1951 let stderr = String::from_utf8_lossy(&output.stderr);
1952 let stdout = String::from_utf8_lossy(&output.stdout);
1953 let error_msg = format!(
1954 "Git CLI pull failed for branch '{}': {}\nStdout: {}\nStderr: {}",
1955 branch, output.status, stdout, stderr
1956 );
1957 Err(CascadeError::Git(git2::Error::from_str(&error_msg)))
1958 }
1959 }
1960
1961 fn force_push_with_git_cli(&self, branch: &str) -> Result<()> {
1964 tracing::debug!(
1965 "Using git CLI fallback for force push operation: {}",
1966 branch
1967 );
1968
1969 let output = std::process::Command::new("git")
1970 .args(["push", "--force", "origin", branch])
1971 .current_dir(&self.path)
1972 .output()
1973 .map_err(|e| CascadeError::branch(format!("Failed to execute git command: {e}")))?;
1974
1975 if output.status.success() {
1976 tracing::debug!("Git CLI force push succeeded for branch: {}", branch);
1977 Ok(())
1978 } else {
1979 let stderr = String::from_utf8_lossy(&output.stderr);
1980 let stdout = String::from_utf8_lossy(&output.stdout);
1981 let error_msg = format!(
1982 "Git CLI force push failed for branch '{}': {}\nStdout: {}\nStderr: {}",
1983 branch, output.status, stdout, stderr
1984 );
1985 Err(CascadeError::branch(error_msg))
1986 }
1987 }
1988
1989 pub fn delete_branch(&self, name: &str) -> Result<()> {
1991 self.delete_branch_with_options(name, false)
1992 }
1993
1994 pub fn delete_branch_unsafe(&self, name: &str) -> Result<()> {
1996 self.delete_branch_with_options(name, true)
1997 }
1998
1999 fn delete_branch_with_options(&self, name: &str, force_unsafe: bool) -> Result<()> {
2001 debug!("Attempting to delete branch: {}", name);
2002
2003 if !force_unsafe {
2005 let safety_result = self.check_branch_deletion_safety(name)?;
2006 if let Some(safety_info) = safety_result {
2007 self.handle_branch_deletion_confirmation(name, &safety_info)?;
2009 }
2010 }
2011
2012 let mut branch = self
2013 .repo
2014 .find_branch(name, git2::BranchType::Local)
2015 .map_err(|e| CascadeError::branch(format!("Could not find branch '{name}': {e}")))?;
2016
2017 branch
2018 .delete()
2019 .map_err(|e| CascadeError::branch(format!("Could not delete branch '{name}': {e}")))?;
2020
2021 debug!("Successfully deleted branch '{}'", name);
2022 Ok(())
2023 }
2024
2025 pub fn get_commits_between(&self, from: &str, to: &str) -> Result<Vec<git2::Commit<'_>>> {
2027 let from_oid = self
2028 .repo
2029 .refname_to_id(&format!("refs/heads/{from}"))
2030 .or_else(|_| Oid::from_str(from))
2031 .map_err(|e| CascadeError::branch(format!("Invalid from reference '{from}': {e}")))?;
2032
2033 let to_oid = self
2034 .repo
2035 .refname_to_id(&format!("refs/heads/{to}"))
2036 .or_else(|_| Oid::from_str(to))
2037 .map_err(|e| CascadeError::branch(format!("Invalid to reference '{to}': {e}")))?;
2038
2039 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
2040
2041 revwalk.push(to_oid).map_err(CascadeError::Git)?;
2042 revwalk.hide(from_oid).map_err(CascadeError::Git)?;
2043
2044 let mut commits = Vec::new();
2045 for oid in revwalk {
2046 let oid = oid.map_err(CascadeError::Git)?;
2047 let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
2048 commits.push(commit);
2049 }
2050
2051 Ok(commits)
2052 }
2053
2054 pub fn force_push_branch(&self, target_branch: &str, source_branch: &str) -> Result<()> {
2057 self.force_push_branch_with_options(target_branch, source_branch, false)
2058 }
2059
2060 pub fn force_push_branch_unsafe(&self, target_branch: &str, source_branch: &str) -> Result<()> {
2062 self.force_push_branch_with_options(target_branch, source_branch, true)
2063 }
2064
2065 fn force_push_branch_with_options(
2067 &self,
2068 target_branch: &str,
2069 source_branch: &str,
2070 force_unsafe: bool,
2071 ) -> Result<()> {
2072 debug!(
2073 "Force pushing {} content to {} to preserve PR history",
2074 source_branch, target_branch
2075 );
2076
2077 if !force_unsafe {
2079 let safety_result = self.check_force_push_safety_enhanced(target_branch)?;
2080 if let Some(backup_info) = safety_result {
2081 self.create_backup_branch(target_branch, &backup_info.remote_commit_id)?;
2083 Output::sub_item(format!(
2084 "Created backup branch: {}",
2085 backup_info.backup_branch_name
2086 ));
2087 }
2088 }
2089
2090 let source_ref = self
2092 .repo
2093 .find_reference(&format!("refs/heads/{source_branch}"))
2094 .map_err(|e| {
2095 CascadeError::config(format!("Failed to find source branch {source_branch}: {e}"))
2096 })?;
2097 let _source_commit = source_ref.peel_to_commit().map_err(|e| {
2098 CascadeError::config(format!(
2099 "Failed to get commit for source branch {source_branch}: {e}"
2100 ))
2101 })?;
2102
2103 let mut remote = self
2105 .repo
2106 .find_remote("origin")
2107 .map_err(|e| CascadeError::config(format!("Failed to find origin remote: {e}")))?;
2108
2109 let refspec = format!("+refs/heads/{source_branch}:refs/heads/{target_branch}");
2111
2112 let callbacks = self.configure_remote_callbacks()?;
2114
2115 let mut push_options = git2::PushOptions::new();
2117 push_options.remote_callbacks(callbacks);
2118
2119 match remote.push(&[&refspec], Some(&mut push_options)) {
2120 Ok(_) => {}
2121 Err(e) => {
2122 if self.should_retry_with_default_credentials(&e) {
2123 tracing::debug!(
2124 "Authentication error detected (class: {:?}, code: {:?}): {}, retrying with DefaultCredentials",
2125 e.class(), e.code(), e
2126 );
2127
2128 let callbacks = self.configure_remote_callbacks_with_fallback(true)?;
2130 let mut push_options = git2::PushOptions::new();
2131 push_options.remote_callbacks(callbacks);
2132
2133 match remote.push(&[&refspec], Some(&mut push_options)) {
2134 Ok(_) => {
2135 tracing::debug!("Force push succeeded with DefaultCredentials");
2136 }
2138 Err(retry_error) => {
2139 tracing::debug!(
2140 "DefaultCredentials retry failed: {}, falling back to git CLI",
2141 retry_error
2142 );
2143 return self.force_push_with_git_cli(target_branch);
2144 }
2145 }
2146 } else if self.should_fallback_to_git_cli(&e) {
2147 tracing::debug!(
2148 "Network/SSL error detected (class: {:?}, code: {:?}): {}, falling back to git CLI for force push operation",
2149 e.class(), e.code(), e
2150 );
2151 return self.force_push_with_git_cli(target_branch);
2152 } else {
2153 return Err(CascadeError::config(format!(
2154 "Failed to force push {target_branch}: {e}"
2155 )));
2156 }
2157 }
2158 }
2159
2160 tracing::debug!(
2161 "Successfully force pushed {} to preserve PR history",
2162 target_branch
2163 );
2164 Ok(())
2165 }
2166
2167 fn check_force_push_safety_enhanced(
2170 &self,
2171 target_branch: &str,
2172 ) -> Result<Option<ForceBackupInfo>> {
2173 match self.fetch() {
2175 Ok(_) => {}
2176 Err(e) => {
2177 debug!("Could not fetch latest changes for safety check: {}", e);
2179 }
2180 }
2181
2182 let remote_ref = format!("refs/remotes/origin/{target_branch}");
2184 let local_ref = format!("refs/heads/{target_branch}");
2185
2186 let local_commit = match self.repo.find_reference(&local_ref) {
2188 Ok(reference) => reference.peel_to_commit().ok(),
2189 Err(_) => None,
2190 };
2191
2192 let remote_commit = match self.repo.find_reference(&remote_ref) {
2193 Ok(reference) => reference.peel_to_commit().ok(),
2194 Err(_) => None,
2195 };
2196
2197 if let (Some(local), Some(remote)) = (local_commit, remote_commit) {
2199 if local.id() != remote.id() {
2200 let merge_base_oid = self
2202 .repo
2203 .merge_base(local.id(), remote.id())
2204 .map_err(|e| CascadeError::config(format!("Failed to find merge base: {e}")))?;
2205
2206 if merge_base_oid != remote.id() {
2208 let commits_to_lose = self.count_commits_between(
2209 &merge_base_oid.to_string(),
2210 &remote.id().to_string(),
2211 )?;
2212
2213 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
2215 let backup_branch_name = format!("{target_branch}_backup_{timestamp}");
2216
2217 debug!(
2218 "Force push to '{}' would overwrite {} commits on remote",
2219 target_branch, commits_to_lose
2220 );
2221
2222 if std::env::var("CI").is_ok() || std::env::var("FORCE_PUSH_NO_CONFIRM").is_ok()
2224 {
2225 info!(
2226 "Non-interactive environment detected, proceeding with backup creation"
2227 );
2228 return Ok(Some(ForceBackupInfo {
2229 backup_branch_name,
2230 remote_commit_id: remote.id().to_string(),
2231 commits_that_would_be_lost: commits_to_lose,
2232 }));
2233 }
2234
2235 return Ok(Some(ForceBackupInfo {
2237 backup_branch_name,
2238 remote_commit_id: remote.id().to_string(),
2239 commits_that_would_be_lost: commits_to_lose,
2240 }));
2241 }
2242 }
2243 }
2244
2245 Ok(None)
2246 }
2247
2248 fn check_force_push_safety_auto_no_fetch(
2254 &self,
2255 target_branch: &str,
2256 ) -> Result<Option<ForceBackupInfo>> {
2257 let remote_ref = format!("refs/remotes/origin/{target_branch}");
2259 let local_ref = format!("refs/heads/{target_branch}");
2260
2261 let local_commit = match self.repo.find_reference(&local_ref) {
2263 Ok(reference) => reference.peel_to_commit().ok(),
2264 Err(_) => None,
2265 };
2266
2267 let remote_commit = match self.repo.find_reference(&remote_ref) {
2268 Ok(reference) => reference.peel_to_commit().ok(),
2269 Err(_) => None,
2270 };
2271
2272 if let (Some(local), Some(remote)) = (local_commit, remote_commit) {
2274 if local.id() != remote.id() {
2275 let merge_base_oid = self
2277 .repo
2278 .merge_base(local.id(), remote.id())
2279 .map_err(|e| CascadeError::config(format!("Failed to find merge base: {e}")))?;
2280
2281 if merge_base_oid != remote.id() {
2283 let commits_to_lose = self.count_commits_between(
2284 &merge_base_oid.to_string(),
2285 &remote.id().to_string(),
2286 )?;
2287
2288 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
2290 let backup_branch_name = format!("{target_branch}_backup_{timestamp}");
2291
2292 tracing::debug!(
2293 "Auto-creating backup '{}' for force push to '{}' (would overwrite {} commits)",
2294 backup_branch_name, target_branch, commits_to_lose
2295 );
2296
2297 return Ok(Some(ForceBackupInfo {
2299 backup_branch_name,
2300 remote_commit_id: remote.id().to_string(),
2301 commits_that_would_be_lost: commits_to_lose,
2302 }));
2303 }
2304 }
2305 }
2306
2307 Ok(None)
2308 }
2309
2310 fn create_backup_branch(&self, original_branch: &str, remote_commit_id: &str) -> Result<()> {
2312 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
2313 let backup_branch_name = format!("{original_branch}_backup_{timestamp}");
2314
2315 let commit_oid = Oid::from_str(remote_commit_id).map_err(|e| {
2317 CascadeError::config(format!("Invalid commit ID {remote_commit_id}: {e}"))
2318 })?;
2319
2320 let commit = self.repo.find_commit(commit_oid).map_err(|e| {
2322 CascadeError::config(format!("Failed to find commit {remote_commit_id}: {e}"))
2323 })?;
2324
2325 self.repo
2327 .branch(&backup_branch_name, &commit, false)
2328 .map_err(|e| {
2329 CascadeError::config(format!(
2330 "Failed to create backup branch {backup_branch_name}: {e}"
2331 ))
2332 })?;
2333
2334 debug!(
2335 "Created backup branch '{}' pointing to {}",
2336 backup_branch_name,
2337 &remote_commit_id[..8]
2338 );
2339 Ok(())
2340 }
2341
2342 fn check_branch_deletion_safety(
2345 &self,
2346 branch_name: &str,
2347 ) -> Result<Option<BranchDeletionSafety>> {
2348 match self.fetch() {
2350 Ok(_) => {}
2351 Err(e) => {
2352 warn!(
2353 "Could not fetch latest changes for branch deletion safety check: {}",
2354 e
2355 );
2356 }
2357 }
2358
2359 let branch = self
2361 .repo
2362 .find_branch(branch_name, git2::BranchType::Local)
2363 .map_err(|e| {
2364 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
2365 })?;
2366
2367 let _branch_commit = branch.get().peel_to_commit().map_err(|e| {
2368 CascadeError::branch(format!(
2369 "Could not get commit for branch '{branch_name}': {e}"
2370 ))
2371 })?;
2372
2373 let main_branch_name = self.detect_main_branch()?;
2375
2376 let is_merged_to_main = self.is_branch_merged_to_main(branch_name, &main_branch_name)?;
2378
2379 let remote_tracking_branch = self.get_remote_tracking_branch(branch_name);
2381
2382 let mut unpushed_commits = Vec::new();
2383
2384 if let Some(ref remote_branch) = remote_tracking_branch {
2386 match self.get_commits_between(remote_branch, branch_name) {
2387 Ok(commits) => {
2388 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
2389 }
2390 Err(_) => {
2391 if !is_merged_to_main {
2393 if let Ok(commits) =
2394 self.get_commits_between(&main_branch_name, branch_name)
2395 {
2396 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
2397 }
2398 }
2399 }
2400 }
2401 } else if !is_merged_to_main {
2402 if let Ok(commits) = self.get_commits_between(&main_branch_name, branch_name) {
2404 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
2405 }
2406 }
2407
2408 if !unpushed_commits.is_empty() || (!is_merged_to_main && remote_tracking_branch.is_none())
2410 {
2411 Ok(Some(BranchDeletionSafety {
2412 unpushed_commits,
2413 remote_tracking_branch,
2414 is_merged_to_main,
2415 main_branch_name,
2416 }))
2417 } else {
2418 Ok(None)
2419 }
2420 }
2421
2422 fn handle_branch_deletion_confirmation(
2424 &self,
2425 branch_name: &str,
2426 safety_info: &BranchDeletionSafety,
2427 ) -> Result<()> {
2428 if std::env::var("CI").is_ok() || std::env::var("BRANCH_DELETE_NO_CONFIRM").is_ok() {
2430 return Err(CascadeError::branch(
2431 format!(
2432 "Branch '{branch_name}' has {} unpushed commits and cannot be deleted in non-interactive mode. Use --force to override.",
2433 safety_info.unpushed_commits.len()
2434 )
2435 ));
2436 }
2437
2438 println!();
2440 Output::warning("BRANCH DELETION WARNING");
2441 println!("Branch '{branch_name}' has potential issues:");
2442
2443 if !safety_info.unpushed_commits.is_empty() {
2444 println!(
2445 "\n🔍 Unpushed commits ({} total):",
2446 safety_info.unpushed_commits.len()
2447 );
2448
2449 for (i, commit_id) in safety_info.unpushed_commits.iter().take(5).enumerate() {
2451 if let Ok(oid) = Oid::from_str(commit_id) {
2452 if let Ok(commit) = self.repo.find_commit(oid) {
2453 let short_hash = &commit_id[..8];
2454 let summary = commit.summary().unwrap_or("<no message>");
2455 println!(" {}. {} - {}", i + 1, short_hash, summary);
2456 }
2457 }
2458 }
2459
2460 if safety_info.unpushed_commits.len() > 5 {
2461 println!(
2462 " ... and {} more commits",
2463 safety_info.unpushed_commits.len() - 5
2464 );
2465 }
2466 }
2467
2468 if !safety_info.is_merged_to_main {
2469 println!();
2470 crate::cli::output::Output::section("Branch status");
2471 crate::cli::output::Output::bullet(format!(
2472 "Not merged to '{}'",
2473 safety_info.main_branch_name
2474 ));
2475 if let Some(ref remote) = safety_info.remote_tracking_branch {
2476 crate::cli::output::Output::bullet(format!("Remote tracking branch: {remote}"));
2477 } else {
2478 crate::cli::output::Output::bullet("No remote tracking branch");
2479 }
2480 }
2481
2482 println!();
2483 crate::cli::output::Output::section("Safer alternatives");
2484 if !safety_info.unpushed_commits.is_empty() {
2485 if let Some(ref _remote) = safety_info.remote_tracking_branch {
2486 println!(" • Push commits first: git push origin {branch_name}");
2487 } else {
2488 println!(" • Create and push to remote: git push -u origin {branch_name}");
2489 }
2490 }
2491 if !safety_info.is_merged_to_main {
2492 println!(
2493 " • Merge to {} first: git checkout {} && git merge {branch_name}",
2494 safety_info.main_branch_name, safety_info.main_branch_name
2495 );
2496 }
2497
2498 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
2499 .with_prompt("Do you want to proceed with deleting this branch?")
2500 .default(false)
2501 .interact()
2502 .map_err(|e| CascadeError::branch(format!("Failed to get user confirmation: {e}")))?;
2503
2504 if !confirmed {
2505 return Err(CascadeError::branch(
2506 "Branch deletion cancelled by user. Use --force to bypass this check.".to_string(),
2507 ));
2508 }
2509
2510 Ok(())
2511 }
2512
2513 pub fn detect_main_branch(&self) -> Result<String> {
2515 let main_candidates = ["main", "master", "develop", "trunk"];
2516
2517 for candidate in &main_candidates {
2518 if self
2519 .repo
2520 .find_branch(candidate, git2::BranchType::Local)
2521 .is_ok()
2522 {
2523 return Ok(candidate.to_string());
2524 }
2525 }
2526
2527 if let Ok(head) = self.repo.head() {
2529 if let Some(name) = head.shorthand() {
2530 return Ok(name.to_string());
2531 }
2532 }
2533
2534 Ok("main".to_string())
2536 }
2537
2538 fn is_branch_merged_to_main(&self, branch_name: &str, main_branch: &str) -> Result<bool> {
2540 match self.get_commits_between(main_branch, branch_name) {
2542 Ok(commits) => Ok(commits.is_empty()),
2543 Err(_) => {
2544 Ok(false)
2546 }
2547 }
2548 }
2549
2550 fn get_remote_tracking_branch(&self, branch_name: &str) -> Option<String> {
2552 let remote_candidates = [
2554 format!("origin/{branch_name}"),
2555 format!("remotes/origin/{branch_name}"),
2556 ];
2557
2558 for candidate in &remote_candidates {
2559 if self
2560 .repo
2561 .find_reference(&format!(
2562 "refs/remotes/{}",
2563 candidate.replace("remotes/", "")
2564 ))
2565 .is_ok()
2566 {
2567 return Some(candidate.clone());
2568 }
2569 }
2570
2571 None
2572 }
2573
2574 fn check_checkout_safety(&self, _target: &str) -> Result<Option<CheckoutSafety>> {
2576 let is_dirty = self.is_dirty()?;
2578 if !is_dirty {
2579 return Ok(None);
2581 }
2582
2583 let current_branch = self.get_current_branch().ok();
2585
2586 let modified_files = self.get_modified_files()?;
2588 let staged_files = self.get_staged_files()?;
2589 let untracked_files = self.get_untracked_files()?;
2590
2591 let has_uncommitted_changes = !modified_files.is_empty() || !staged_files.is_empty();
2592
2593 if has_uncommitted_changes || !untracked_files.is_empty() {
2594 return Ok(Some(CheckoutSafety {
2595 has_uncommitted_changes,
2596 modified_files,
2597 staged_files,
2598 untracked_files,
2599 stash_created: None,
2600 current_branch,
2601 }));
2602 }
2603
2604 Ok(None)
2605 }
2606
2607 fn handle_checkout_confirmation(
2609 &self,
2610 target: &str,
2611 safety_info: &CheckoutSafety,
2612 ) -> Result<()> {
2613 let is_ci = std::env::var("CI").is_ok();
2615 let no_confirm = std::env::var("CHECKOUT_NO_CONFIRM").is_ok();
2616 let is_non_interactive = is_ci || no_confirm;
2617
2618 if is_non_interactive {
2619 return Err(CascadeError::branch(
2620 format!(
2621 "Cannot checkout '{target}' with uncommitted changes in non-interactive mode. Commit your changes or use stash first."
2622 )
2623 ));
2624 }
2625
2626 println!("\nCHECKOUT WARNING");
2628 println!("Attempting to checkout: {}", target);
2629 println!("You have uncommitted changes that could be lost:");
2630
2631 if !safety_info.modified_files.is_empty() {
2632 println!("\nModified files ({}):", safety_info.modified_files.len());
2633 for file in safety_info.modified_files.iter().take(10) {
2634 println!(" - {file}");
2635 }
2636 if safety_info.modified_files.len() > 10 {
2637 println!(" ... and {} more", safety_info.modified_files.len() - 10);
2638 }
2639 }
2640
2641 if !safety_info.staged_files.is_empty() {
2642 println!("\nStaged files ({}):", safety_info.staged_files.len());
2643 for file in safety_info.staged_files.iter().take(10) {
2644 println!(" - {file}");
2645 }
2646 if safety_info.staged_files.len() > 10 {
2647 println!(" ... and {} more", safety_info.staged_files.len() - 10);
2648 }
2649 }
2650
2651 if !safety_info.untracked_files.is_empty() {
2652 println!("\nUntracked files ({}):", safety_info.untracked_files.len());
2653 for file in safety_info.untracked_files.iter().take(5) {
2654 println!(" - {file}");
2655 }
2656 if safety_info.untracked_files.len() > 5 {
2657 println!(" ... and {} more", safety_info.untracked_files.len() - 5);
2658 }
2659 }
2660
2661 println!("\nOptions:");
2662 println!("1. Stash changes and checkout (recommended)");
2663 println!("2. Force checkout (WILL LOSE UNCOMMITTED CHANGES)");
2664 println!("3. Cancel checkout");
2665
2666 let selection = Select::with_theme(&ColorfulTheme::default())
2668 .with_prompt("Choose an action")
2669 .items(&[
2670 "Stash changes and checkout (recommended)",
2671 "Force checkout (WILL LOSE UNCOMMITTED CHANGES)",
2672 "Cancel checkout",
2673 ])
2674 .default(0)
2675 .interact()
2676 .map_err(|e| CascadeError::branch(format!("Could not get user selection: {e}")))?;
2677
2678 match selection {
2679 0 => {
2680 let stash_message = format!(
2682 "Auto-stash before checkout to {} at {}",
2683 target,
2684 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
2685 );
2686
2687 match self.create_stash(&stash_message) {
2688 Ok(stash_id) => {
2689 crate::cli::output::Output::success(format!(
2690 "Created stash: {stash_message} ({stash_id})"
2691 ));
2692 crate::cli::output::Output::tip("You can restore with: git stash pop");
2693 }
2694 Err(e) => {
2695 crate::cli::output::Output::error(format!("Failed to create stash: {e}"));
2696
2697 use dialoguer::Select;
2699 let stash_failed_options = vec![
2700 "Commit staged changes and proceed",
2701 "Force checkout (WILL LOSE CHANGES)",
2702 "Cancel and handle manually",
2703 ];
2704
2705 let stash_selection = Select::with_theme(&ColorfulTheme::default())
2706 .with_prompt("Stash failed. What would you like to do?")
2707 .items(&stash_failed_options)
2708 .default(0)
2709 .interact()
2710 .map_err(|e| {
2711 CascadeError::branch(format!("Could not get user selection: {e}"))
2712 })?;
2713
2714 match stash_selection {
2715 0 => {
2716 let staged_files = self.get_staged_files()?;
2718 if !staged_files.is_empty() {
2719 println!(
2720 "📝 Committing {} staged files...",
2721 staged_files.len()
2722 );
2723 match self
2724 .commit_staged_changes("WIP: Auto-commit before checkout")
2725 {
2726 Ok(Some(commit_hash)) => {
2727 crate::cli::output::Output::success(format!(
2728 "Committed staged changes as {}",
2729 &commit_hash[..8]
2730 ));
2731 crate::cli::output::Output::tip(
2732 "You can undo with: git reset HEAD~1",
2733 );
2734 }
2735 Ok(None) => {
2736 crate::cli::output::Output::info(
2737 "No staged changes found to commit",
2738 );
2739 }
2740 Err(commit_err) => {
2741 println!(
2742 "❌ Failed to commit staged changes: {commit_err}"
2743 );
2744 return Err(CascadeError::branch(
2745 "Could not commit staged changes".to_string(),
2746 ));
2747 }
2748 }
2749 } else {
2750 println!("No staged changes to commit");
2751 }
2752 }
2753 1 => {
2754 Output::warning("Proceeding with force checkout - uncommitted changes will be lost!");
2756 }
2757 2 => {
2758 return Err(CascadeError::branch(
2760 "Checkout cancelled. Please handle changes manually and try again.".to_string(),
2761 ));
2762 }
2763 _ => unreachable!(),
2764 }
2765 }
2766 }
2767 }
2768 1 => {
2769 Output::warning(
2771 "Proceeding with force checkout - uncommitted changes will be lost!",
2772 );
2773 }
2774 2 => {
2775 return Err(CascadeError::branch(
2777 "Checkout cancelled by user".to_string(),
2778 ));
2779 }
2780 _ => unreachable!(),
2781 }
2782
2783 Ok(())
2784 }
2785
2786 fn create_stash(&self, message: &str) -> Result<String> {
2788 use crate::cli::output::Output;
2789
2790 tracing::debug!("Creating stash: {}", message);
2791
2792 let output = std::process::Command::new("git")
2794 .args(["stash", "push", "-m", message])
2795 .current_dir(&self.path)
2796 .output()
2797 .map_err(|e| {
2798 CascadeError::branch(format!("Failed to execute git stash command: {e}"))
2799 })?;
2800
2801 if output.status.success() {
2802 let stdout = String::from_utf8_lossy(&output.stdout);
2803
2804 let stash_id = if stdout.contains("Saved working directory") {
2806 let stash_list_output = std::process::Command::new("git")
2808 .args(["stash", "list", "-n", "1", "--format=%H"])
2809 .current_dir(&self.path)
2810 .output()
2811 .map_err(|e| CascadeError::branch(format!("Failed to get stash ID: {e}")))?;
2812
2813 if stash_list_output.status.success() {
2814 String::from_utf8_lossy(&stash_list_output.stdout)
2815 .trim()
2816 .to_string()
2817 } else {
2818 "stash@{0}".to_string() }
2820 } else {
2821 "stash@{0}".to_string() };
2823
2824 Output::success(format!("Created stash: {} ({})", message, stash_id));
2825 Output::tip("You can restore with: git stash pop");
2826 Ok(stash_id)
2827 } else {
2828 let stderr = String::from_utf8_lossy(&output.stderr);
2829 let stdout = String::from_utf8_lossy(&output.stdout);
2830
2831 if stderr.contains("No local changes to save")
2833 || stdout.contains("No local changes to save")
2834 {
2835 return Err(CascadeError::branch("No local changes to save".to_string()));
2836 }
2837
2838 Err(CascadeError::branch(format!(
2839 "Failed to create stash: {}\nStderr: {}\nStdout: {}",
2840 output.status, stderr, stdout
2841 )))
2842 }
2843 }
2844
2845 fn get_modified_files(&self) -> Result<Vec<String>> {
2847 let mut opts = git2::StatusOptions::new();
2848 opts.include_untracked(false).include_ignored(false);
2849
2850 let statuses = self
2851 .repo
2852 .statuses(Some(&mut opts))
2853 .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
2854
2855 let mut modified_files = Vec::new();
2856 for status in statuses.iter() {
2857 let flags = status.status();
2858 if flags.contains(git2::Status::WT_MODIFIED) || flags.contains(git2::Status::WT_DELETED)
2859 {
2860 if let Some(path) = status.path() {
2861 modified_files.push(path.to_string());
2862 }
2863 }
2864 }
2865
2866 Ok(modified_files)
2867 }
2868
2869 pub fn get_staged_files(&self) -> Result<Vec<String>> {
2871 let mut opts = git2::StatusOptions::new();
2872 opts.include_untracked(false).include_ignored(false);
2873
2874 let statuses = self
2875 .repo
2876 .statuses(Some(&mut opts))
2877 .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
2878
2879 let mut staged_files = Vec::new();
2880 for status in statuses.iter() {
2881 let flags = status.status();
2882 if flags.contains(git2::Status::INDEX_MODIFIED)
2883 || flags.contains(git2::Status::INDEX_NEW)
2884 || flags.contains(git2::Status::INDEX_DELETED)
2885 {
2886 if let Some(path) = status.path() {
2887 staged_files.push(path.to_string());
2888 }
2889 }
2890 }
2891
2892 Ok(staged_files)
2893 }
2894
2895 fn count_commits_between(&self, from: &str, to: &str) -> Result<usize> {
2897 let commits = self.get_commits_between(from, to)?;
2898 Ok(commits.len())
2899 }
2900
2901 pub fn resolve_reference(&self, reference: &str) -> Result<git2::Commit<'_>> {
2903 if let Ok(oid) = Oid::from_str(reference) {
2905 if let Ok(commit) = self.repo.find_commit(oid) {
2906 return Ok(commit);
2907 }
2908 }
2909
2910 let obj = self.repo.revparse_single(reference).map_err(|e| {
2912 CascadeError::branch(format!("Could not resolve reference '{reference}': {e}"))
2913 })?;
2914
2915 obj.peel_to_commit().map_err(|e| {
2916 CascadeError::branch(format!(
2917 "Reference '{reference}' does not point to a commit: {e}"
2918 ))
2919 })
2920 }
2921
2922 pub fn reset_soft(&self, target_ref: &str) -> Result<()> {
2924 let target_commit = self.resolve_reference(target_ref)?;
2925
2926 self.repo
2927 .reset(target_commit.as_object(), git2::ResetType::Soft, None)
2928 .map_err(CascadeError::Git)?;
2929
2930 Ok(())
2931 }
2932
2933 pub fn reset_to_head(&self) -> Result<()> {
2936 tracing::debug!("Resetting working directory and index to HEAD");
2937
2938 let repo_path = self.path();
2939
2940 crate::utils::git_lock::with_lock_retry(repo_path, || {
2942 let head = self.repo.head()?;
2943 let head_commit = head.peel_to_commit()?;
2944
2945 let mut checkout_builder = git2::build::CheckoutBuilder::new();
2947 checkout_builder.force(); checkout_builder.remove_untracked(false); self.repo.reset(
2951 head_commit.as_object(),
2952 git2::ResetType::Hard,
2953 Some(&mut checkout_builder),
2954 )?;
2955
2956 Ok(())
2957 })?;
2958
2959 tracing::debug!("Successfully reset working directory to HEAD");
2960 Ok(())
2961 }
2962
2963 pub fn find_branch_containing_commit(&self, commit_hash: &str) -> Result<String> {
2965 let oid = Oid::from_str(commit_hash).map_err(|e| {
2966 CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
2967 })?;
2968
2969 let branches = self
2971 .repo
2972 .branches(Some(git2::BranchType::Local))
2973 .map_err(CascadeError::Git)?;
2974
2975 for branch_result in branches {
2976 let (branch, _) = branch_result.map_err(CascadeError::Git)?;
2977
2978 if let Some(branch_name) = branch.name().map_err(CascadeError::Git)? {
2979 if let Ok(branch_head) = branch.get().peel_to_commit() {
2981 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
2983 revwalk.push(branch_head.id()).map_err(CascadeError::Git)?;
2984
2985 for commit_oid in revwalk {
2986 let commit_oid = commit_oid.map_err(CascadeError::Git)?;
2987 if commit_oid == oid {
2988 return Ok(branch_name.to_string());
2989 }
2990 }
2991 }
2992 }
2993 }
2994
2995 Err(CascadeError::branch(format!(
2997 "Commit {commit_hash} not found in any local branch"
2998 )))
2999 }
3000
3001 pub async fn fetch_async(&self) -> Result<()> {
3005 let repo_path = self.path.clone();
3006 crate::utils::async_ops::run_git_operation(move || {
3007 let repo = GitRepository::open(&repo_path)?;
3008 repo.fetch()
3009 })
3010 .await
3011 }
3012
3013 pub async fn pull_async(&self, branch: &str) -> Result<()> {
3015 let repo_path = self.path.clone();
3016 let branch_name = branch.to_string();
3017 crate::utils::async_ops::run_git_operation(move || {
3018 let repo = GitRepository::open(&repo_path)?;
3019 repo.pull(&branch_name)
3020 })
3021 .await
3022 }
3023
3024 pub async fn push_branch_async(&self, branch_name: &str) -> Result<()> {
3026 let repo_path = self.path.clone();
3027 let branch = branch_name.to_string();
3028 crate::utils::async_ops::run_git_operation(move || {
3029 let repo = GitRepository::open(&repo_path)?;
3030 repo.push(&branch)
3031 })
3032 .await
3033 }
3034
3035 pub async fn cherry_pick_commit_async(&self, commit_hash: &str) -> Result<String> {
3037 let repo_path = self.path.clone();
3038 let hash = commit_hash.to_string();
3039 crate::utils::async_ops::run_git_operation(move || {
3040 let repo = GitRepository::open(&repo_path)?;
3041 repo.cherry_pick(&hash)
3042 })
3043 .await
3044 }
3045
3046 pub async fn get_commit_hashes_between_async(
3048 &self,
3049 from: &str,
3050 to: &str,
3051 ) -> Result<Vec<String>> {
3052 let repo_path = self.path.clone();
3053 let from_str = from.to_string();
3054 let to_str = to.to_string();
3055 crate::utils::async_ops::run_git_operation(move || {
3056 let repo = GitRepository::open(&repo_path)?;
3057 let commits = repo.get_commits_between(&from_str, &to_str)?;
3058 Ok(commits.into_iter().map(|c| c.id().to_string()).collect())
3059 })
3060 .await
3061 }
3062
3063 pub fn reset_branch_to_commit(&self, branch_name: &str, commit_hash: &str) -> Result<()> {
3065 info!(
3066 "Resetting branch '{}' to commit {}",
3067 branch_name,
3068 &commit_hash[..8]
3069 );
3070
3071 let target_oid = git2::Oid::from_str(commit_hash).map_err(|e| {
3073 CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
3074 })?;
3075
3076 let _target_commit = self.repo.find_commit(target_oid).map_err(|e| {
3077 CascadeError::branch(format!("Could not find commit '{commit_hash}': {e}"))
3078 })?;
3079
3080 let _branch = self
3082 .repo
3083 .find_branch(branch_name, git2::BranchType::Local)
3084 .map_err(|e| {
3085 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
3086 })?;
3087
3088 let branch_ref_name = format!("refs/heads/{branch_name}");
3090 self.repo
3091 .reference(
3092 &branch_ref_name,
3093 target_oid,
3094 true,
3095 &format!("Reset {branch_name} to {commit_hash}"),
3096 )
3097 .map_err(|e| {
3098 CascadeError::branch(format!(
3099 "Could not reset branch '{branch_name}' to commit '{commit_hash}': {e}"
3100 ))
3101 })?;
3102
3103 tracing::info!(
3104 "Successfully reset branch '{}' to commit {}",
3105 branch_name,
3106 &commit_hash[..8]
3107 );
3108 Ok(())
3109 }
3110
3111 pub fn detect_parent_branch(&self) -> Result<Option<String>> {
3113 let current_branch = self.get_current_branch()?;
3114
3115 if let Ok(Some(upstream)) = self.get_upstream_branch(¤t_branch) {
3117 if let Some(branch_name) = upstream.split('/').nth(1) {
3119 if self.branch_exists(branch_name) {
3120 tracing::debug!(
3121 "Detected parent branch '{}' from upstream tracking",
3122 branch_name
3123 );
3124 return Ok(Some(branch_name.to_string()));
3125 }
3126 }
3127 }
3128
3129 if let Ok(default_branch) = self.detect_main_branch() {
3131 if current_branch != default_branch {
3133 tracing::debug!(
3134 "Detected parent branch '{}' as repository default",
3135 default_branch
3136 );
3137 return Ok(Some(default_branch));
3138 }
3139 }
3140
3141 if let Ok(branches) = self.list_branches() {
3144 let current_commit = self.get_head_commit()?;
3145 let current_commit_hash = current_commit.id().to_string();
3146 let current_oid = current_commit.id();
3147
3148 let mut best_candidate = None;
3149 let mut best_distance = usize::MAX;
3150
3151 for branch in branches {
3152 if branch == current_branch
3154 || branch.contains("-v")
3155 || branch.ends_with("-v2")
3156 || branch.ends_with("-v3")
3157 {
3158 continue;
3159 }
3160
3161 if let Ok(base_commit_hash) = self.get_branch_commit_hash(&branch) {
3162 if let Ok(base_oid) = git2::Oid::from_str(&base_commit_hash) {
3163 if let Ok(merge_base_oid) = self.repo.merge_base(current_oid, base_oid) {
3165 if let Ok(distance) = self.count_commits_between(
3167 &merge_base_oid.to_string(),
3168 ¤t_commit_hash,
3169 ) {
3170 let is_likely_base = self.is_likely_base_branch(&branch);
3173 let adjusted_distance = if is_likely_base {
3174 distance
3175 } else {
3176 distance + 1000
3177 };
3178
3179 if adjusted_distance < best_distance {
3180 best_distance = adjusted_distance;
3181 best_candidate = Some(branch.clone());
3182 }
3183 }
3184 }
3185 }
3186 }
3187 }
3188
3189 if let Some(ref candidate) = best_candidate {
3190 tracing::debug!(
3191 "Detected parent branch '{}' with distance {}",
3192 candidate,
3193 best_distance
3194 );
3195 }
3196
3197 return Ok(best_candidate);
3198 }
3199
3200 tracing::debug!("Could not detect parent branch for '{}'", current_branch);
3201 Ok(None)
3202 }
3203
3204 fn is_likely_base_branch(&self, branch_name: &str) -> bool {
3206 let base_patterns = [
3207 "main",
3208 "master",
3209 "develop",
3210 "dev",
3211 "development",
3212 "staging",
3213 "stage",
3214 "release",
3215 "production",
3216 "prod",
3217 ];
3218
3219 base_patterns.contains(&branch_name)
3220 }
3221}
3222
3223#[cfg(test)]
3224mod tests {
3225 use super::*;
3226 use std::process::Command;
3227 use tempfile::TempDir;
3228
3229 fn create_test_repo() -> (TempDir, PathBuf) {
3230 let temp_dir = TempDir::new().unwrap();
3231 let repo_path = temp_dir.path().to_path_buf();
3232
3233 Command::new("git")
3235 .args(["init"])
3236 .current_dir(&repo_path)
3237 .output()
3238 .unwrap();
3239 Command::new("git")
3240 .args(["config", "user.name", "Test"])
3241 .current_dir(&repo_path)
3242 .output()
3243 .unwrap();
3244 Command::new("git")
3245 .args(["config", "user.email", "test@test.com"])
3246 .current_dir(&repo_path)
3247 .output()
3248 .unwrap();
3249
3250 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
3252 Command::new("git")
3253 .args(["add", "."])
3254 .current_dir(&repo_path)
3255 .output()
3256 .unwrap();
3257 Command::new("git")
3258 .args(["commit", "-m", "Initial commit"])
3259 .current_dir(&repo_path)
3260 .output()
3261 .unwrap();
3262
3263 (temp_dir, repo_path)
3264 }
3265
3266 fn create_commit(repo_path: &PathBuf, message: &str, filename: &str) {
3267 let file_path = repo_path.join(filename);
3268 std::fs::write(&file_path, format!("Content for {filename}\n")).unwrap();
3269
3270 Command::new("git")
3271 .args(["add", filename])
3272 .current_dir(repo_path)
3273 .output()
3274 .unwrap();
3275 Command::new("git")
3276 .args(["commit", "-m", message])
3277 .current_dir(repo_path)
3278 .output()
3279 .unwrap();
3280 }
3281
3282 #[test]
3283 fn test_repository_info() {
3284 let (_temp_dir, repo_path) = create_test_repo();
3285 let repo = GitRepository::open(&repo_path).unwrap();
3286
3287 let info = repo.get_info().unwrap();
3288 assert!(!info.is_dirty); assert!(
3290 info.head_branch == Some("master".to_string())
3291 || info.head_branch == Some("main".to_string()),
3292 "Expected default branch to be 'master' or 'main', got {:?}",
3293 info.head_branch
3294 );
3295 assert!(info.head_commit.is_some()); assert!(info.untracked_files.is_empty()); }
3298
3299 #[test]
3300 fn test_force_push_branch_basic() {
3301 let (_temp_dir, repo_path) = create_test_repo();
3302 let repo = GitRepository::open(&repo_path).unwrap();
3303
3304 let default_branch = repo.get_current_branch().unwrap();
3306
3307 create_commit(&repo_path, "Feature commit 1", "feature1.rs");
3309 Command::new("git")
3310 .args(["checkout", "-b", "source-branch"])
3311 .current_dir(&repo_path)
3312 .output()
3313 .unwrap();
3314 create_commit(&repo_path, "Feature commit 2", "feature2.rs");
3315
3316 Command::new("git")
3318 .args(["checkout", &default_branch])
3319 .current_dir(&repo_path)
3320 .output()
3321 .unwrap();
3322 Command::new("git")
3323 .args(["checkout", "-b", "target-branch"])
3324 .current_dir(&repo_path)
3325 .output()
3326 .unwrap();
3327 create_commit(&repo_path, "Target commit", "target.rs");
3328
3329 let result = repo.force_push_branch("target-branch", "source-branch");
3331
3332 assert!(result.is_ok() || result.is_err()); }
3336
3337 #[test]
3338 fn test_force_push_branch_nonexistent_branches() {
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 let result = repo.force_push_branch("target", "nonexistent-source");
3347 assert!(result.is_err());
3348
3349 let result = repo.force_push_branch("nonexistent-target", &default_branch);
3351 assert!(result.is_err());
3352 }
3353
3354 #[test]
3355 fn test_force_push_workflow_simulation() {
3356 let (_temp_dir, repo_path) = create_test_repo();
3357 let repo = GitRepository::open(&repo_path).unwrap();
3358
3359 Command::new("git")
3362 .args(["checkout", "-b", "feature-auth"])
3363 .current_dir(&repo_path)
3364 .output()
3365 .unwrap();
3366 create_commit(&repo_path, "Add authentication", "auth.rs");
3367
3368 Command::new("git")
3370 .args(["checkout", "-b", "feature-auth-v2"])
3371 .current_dir(&repo_path)
3372 .output()
3373 .unwrap();
3374 create_commit(&repo_path, "Fix auth validation", "auth.rs");
3375
3376 let result = repo.force_push_branch("feature-auth", "feature-auth-v2");
3378
3379 match result {
3381 Ok(_) => {
3382 Command::new("git")
3384 .args(["checkout", "feature-auth"])
3385 .current_dir(&repo_path)
3386 .output()
3387 .unwrap();
3388 let log_output = Command::new("git")
3389 .args(["log", "--oneline", "-2"])
3390 .current_dir(&repo_path)
3391 .output()
3392 .unwrap();
3393 let log_str = String::from_utf8_lossy(&log_output.stdout);
3394 assert!(
3395 log_str.contains("Fix auth validation")
3396 || log_str.contains("Add authentication")
3397 );
3398 }
3399 Err(_) => {
3400 }
3403 }
3404 }
3405
3406 #[test]
3407 fn test_branch_operations() {
3408 let (_temp_dir, repo_path) = create_test_repo();
3409 let repo = GitRepository::open(&repo_path).unwrap();
3410
3411 let current = repo.get_current_branch().unwrap();
3413 assert!(
3414 current == "master" || current == "main",
3415 "Expected default branch to be 'master' or 'main', got '{current}'"
3416 );
3417
3418 Command::new("git")
3420 .args(["checkout", "-b", "test-branch"])
3421 .current_dir(&repo_path)
3422 .output()
3423 .unwrap();
3424 let current = repo.get_current_branch().unwrap();
3425 assert_eq!(current, "test-branch");
3426 }
3427
3428 #[test]
3429 fn test_commit_operations() {
3430 let (_temp_dir, repo_path) = create_test_repo();
3431 let repo = GitRepository::open(&repo_path).unwrap();
3432
3433 let head = repo.get_head_commit().unwrap();
3435 assert_eq!(head.message().unwrap().trim(), "Initial commit");
3436
3437 let hash = head.id().to_string();
3439 let same_commit = repo.get_commit(&hash).unwrap();
3440 assert_eq!(head.id(), same_commit.id());
3441 }
3442
3443 #[test]
3444 fn test_checkout_safety_clean_repo() {
3445 let (_temp_dir, repo_path) = create_test_repo();
3446 let repo = GitRepository::open(&repo_path).unwrap();
3447
3448 create_commit(&repo_path, "Second commit", "test.txt");
3450 Command::new("git")
3451 .args(["checkout", "-b", "test-branch"])
3452 .current_dir(&repo_path)
3453 .output()
3454 .unwrap();
3455
3456 let safety_result = repo.check_checkout_safety("main");
3458 assert!(safety_result.is_ok());
3459 assert!(safety_result.unwrap().is_none()); }
3461
3462 #[test]
3463 fn test_checkout_safety_with_modified_files() {
3464 let (_temp_dir, repo_path) = create_test_repo();
3465 let repo = GitRepository::open(&repo_path).unwrap();
3466
3467 Command::new("git")
3469 .args(["checkout", "-b", "test-branch"])
3470 .current_dir(&repo_path)
3471 .output()
3472 .unwrap();
3473
3474 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
3476
3477 let safety_result = repo.check_checkout_safety("main");
3479 assert!(safety_result.is_ok());
3480 let safety_info = safety_result.unwrap();
3481 assert!(safety_info.is_some());
3482
3483 let info = safety_info.unwrap();
3484 assert!(!info.modified_files.is_empty());
3485 assert!(info.modified_files.contains(&"README.md".to_string()));
3486 }
3487
3488 #[test]
3489 fn test_unsafe_checkout_methods() {
3490 let (_temp_dir, repo_path) = create_test_repo();
3491 let repo = GitRepository::open(&repo_path).unwrap();
3492
3493 create_commit(&repo_path, "Second commit", "test.txt");
3495 Command::new("git")
3496 .args(["checkout", "-b", "test-branch"])
3497 .current_dir(&repo_path)
3498 .output()
3499 .unwrap();
3500
3501 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
3503
3504 let _result = repo.checkout_branch_unsafe("main");
3506 let head_commit = repo.get_head_commit().unwrap();
3511 let commit_hash = head_commit.id().to_string();
3512 let _result = repo.checkout_commit_unsafe(&commit_hash);
3513 }
3515
3516 #[test]
3517 fn test_get_modified_files() {
3518 let (_temp_dir, repo_path) = create_test_repo();
3519 let repo = GitRepository::open(&repo_path).unwrap();
3520
3521 let modified = repo.get_modified_files().unwrap();
3523 assert!(modified.is_empty());
3524
3525 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
3527
3528 let modified = repo.get_modified_files().unwrap();
3530 assert_eq!(modified.len(), 1);
3531 assert!(modified.contains(&"README.md".to_string()));
3532 }
3533
3534 #[test]
3535 fn test_get_staged_files() {
3536 let (_temp_dir, repo_path) = create_test_repo();
3537 let repo = GitRepository::open(&repo_path).unwrap();
3538
3539 let staged = repo.get_staged_files().unwrap();
3541 assert!(staged.is_empty());
3542
3543 std::fs::write(repo_path.join("staged.txt"), "Staged content").unwrap();
3545 Command::new("git")
3546 .args(["add", "staged.txt"])
3547 .current_dir(&repo_path)
3548 .output()
3549 .unwrap();
3550
3551 let staged = repo.get_staged_files().unwrap();
3553 assert_eq!(staged.len(), 1);
3554 assert!(staged.contains(&"staged.txt".to_string()));
3555 }
3556
3557 #[test]
3558 fn test_create_stash_fallback() {
3559 let (_temp_dir, repo_path) = create_test_repo();
3560 let repo = GitRepository::open(&repo_path).unwrap();
3561
3562 let result = repo.create_stash("test stash");
3564
3565 match result {
3567 Ok(stash_id) => {
3568 assert!(!stash_id.is_empty());
3570 assert!(stash_id.contains("stash") || stash_id.len() >= 7); }
3572 Err(error) => {
3573 let error_msg = error.to_string();
3575 assert!(
3576 error_msg.contains("No local changes to save")
3577 || error_msg.contains("git stash push")
3578 );
3579 }
3580 }
3581 }
3582
3583 #[test]
3584 fn test_delete_branch_unsafe() {
3585 let (_temp_dir, repo_path) = create_test_repo();
3586 let repo = GitRepository::open(&repo_path).unwrap();
3587
3588 create_commit(&repo_path, "Second commit", "test.txt");
3590 Command::new("git")
3591 .args(["checkout", "-b", "test-branch"])
3592 .current_dir(&repo_path)
3593 .output()
3594 .unwrap();
3595
3596 create_commit(&repo_path, "Branch-specific commit", "branch.txt");
3598
3599 Command::new("git")
3601 .args(["checkout", "main"])
3602 .current_dir(&repo_path)
3603 .output()
3604 .unwrap();
3605
3606 let result = repo.delete_branch_unsafe("test-branch");
3609 let _ = result; }
3613
3614 #[test]
3615 fn test_force_push_unsafe() {
3616 let (_temp_dir, repo_path) = create_test_repo();
3617 let repo = GitRepository::open(&repo_path).unwrap();
3618
3619 create_commit(&repo_path, "Second commit", "test.txt");
3621 Command::new("git")
3622 .args(["checkout", "-b", "test-branch"])
3623 .current_dir(&repo_path)
3624 .output()
3625 .unwrap();
3626
3627 let _result = repo.force_push_branch_unsafe("test-branch", "test-branch");
3630 }
3632
3633 #[test]
3634 fn test_cherry_pick_basic() {
3635 let (_temp_dir, repo_path) = create_test_repo();
3636 let repo = GitRepository::open(&repo_path).unwrap();
3637
3638 repo.create_branch("source", None).unwrap();
3640 repo.checkout_branch("source").unwrap();
3641
3642 std::fs::write(repo_path.join("cherry.txt"), "Cherry content").unwrap();
3643 Command::new("git")
3644 .args(["add", "."])
3645 .current_dir(&repo_path)
3646 .output()
3647 .unwrap();
3648
3649 Command::new("git")
3650 .args(["commit", "-m", "Cherry commit"])
3651 .current_dir(&repo_path)
3652 .output()
3653 .unwrap();
3654
3655 let cherry_commit = repo.get_head_commit_hash().unwrap();
3656
3657 Command::new("git")
3660 .args(["checkout", "-"])
3661 .current_dir(&repo_path)
3662 .output()
3663 .unwrap();
3664
3665 repo.create_branch("target", None).unwrap();
3666 repo.checkout_branch("target").unwrap();
3667
3668 let new_commit = repo.cherry_pick(&cherry_commit).unwrap();
3670
3671 repo.repo
3673 .find_commit(git2::Oid::from_str(&new_commit).unwrap())
3674 .unwrap();
3675
3676 assert!(
3678 repo_path.join("cherry.txt").exists(),
3679 "Cherry-picked file should exist"
3680 );
3681
3682 repo.checkout_branch("source").unwrap();
3684 let source_head = repo.get_head_commit_hash().unwrap();
3685 assert_eq!(
3686 source_head, cherry_commit,
3687 "Source branch should be unchanged"
3688 );
3689 }
3690
3691 #[test]
3692 fn test_cherry_pick_preserves_commit_message() {
3693 let (_temp_dir, repo_path) = create_test_repo();
3694 let repo = GitRepository::open(&repo_path).unwrap();
3695
3696 repo.create_branch("msg-test", None).unwrap();
3698 repo.checkout_branch("msg-test").unwrap();
3699
3700 std::fs::write(repo_path.join("msg.txt"), "Content").unwrap();
3701 Command::new("git")
3702 .args(["add", "."])
3703 .current_dir(&repo_path)
3704 .output()
3705 .unwrap();
3706
3707 let commit_msg = "Test: Special commit message\n\nWith body";
3708 Command::new("git")
3709 .args(["commit", "-m", commit_msg])
3710 .current_dir(&repo_path)
3711 .output()
3712 .unwrap();
3713
3714 let original_commit = repo.get_head_commit_hash().unwrap();
3715
3716 Command::new("git")
3718 .args(["checkout", "-"])
3719 .current_dir(&repo_path)
3720 .output()
3721 .unwrap();
3722 let new_commit = repo.cherry_pick(&original_commit).unwrap();
3723
3724 let output = Command::new("git")
3726 .args(["log", "-1", "--format=%B", &new_commit])
3727 .current_dir(&repo_path)
3728 .output()
3729 .unwrap();
3730
3731 let new_msg = String::from_utf8_lossy(&output.stdout);
3732 assert!(
3733 new_msg.contains("Special commit message"),
3734 "Should preserve commit message"
3735 );
3736 }
3737
3738 #[test]
3739 fn test_cherry_pick_handles_conflicts() {
3740 let (_temp_dir, repo_path) = create_test_repo();
3741 let repo = GitRepository::open(&repo_path).unwrap();
3742
3743 std::fs::write(repo_path.join("conflict.txt"), "Original").unwrap();
3745 Command::new("git")
3746 .args(["add", "."])
3747 .current_dir(&repo_path)
3748 .output()
3749 .unwrap();
3750
3751 Command::new("git")
3752 .args(["commit", "-m", "Add conflict file"])
3753 .current_dir(&repo_path)
3754 .output()
3755 .unwrap();
3756
3757 repo.create_branch("conflict-branch", None).unwrap();
3759 repo.checkout_branch("conflict-branch").unwrap();
3760
3761 std::fs::write(repo_path.join("conflict.txt"), "Modified").unwrap();
3762 Command::new("git")
3763 .args(["add", "."])
3764 .current_dir(&repo_path)
3765 .output()
3766 .unwrap();
3767
3768 Command::new("git")
3769 .args(["commit", "-m", "Modify conflict file"])
3770 .current_dir(&repo_path)
3771 .output()
3772 .unwrap();
3773
3774 let conflict_commit = repo.get_head_commit_hash().unwrap();
3775
3776 Command::new("git")
3779 .args(["checkout", "-"])
3780 .current_dir(&repo_path)
3781 .output()
3782 .unwrap();
3783 std::fs::write(repo_path.join("conflict.txt"), "Different").unwrap();
3784 Command::new("git")
3785 .args(["add", "."])
3786 .current_dir(&repo_path)
3787 .output()
3788 .unwrap();
3789
3790 Command::new("git")
3791 .args(["commit", "-m", "Different change"])
3792 .current_dir(&repo_path)
3793 .output()
3794 .unwrap();
3795
3796 let result = repo.cherry_pick(&conflict_commit);
3798 assert!(result.is_err(), "Cherry-pick with conflict should fail");
3799 }
3800
3801 #[test]
3802 fn test_reset_to_head_clears_staged_files() {
3803 let (_temp_dir, repo_path) = create_test_repo();
3804 let repo = GitRepository::open(&repo_path).unwrap();
3805
3806 std::fs::write(repo_path.join("staged1.txt"), "Content 1").unwrap();
3808 std::fs::write(repo_path.join("staged2.txt"), "Content 2").unwrap();
3809
3810 Command::new("git")
3811 .args(["add", "staged1.txt", "staged2.txt"])
3812 .current_dir(&repo_path)
3813 .output()
3814 .unwrap();
3815
3816 let staged_before = repo.get_staged_files().unwrap();
3818 assert_eq!(staged_before.len(), 2, "Should have 2 staged files");
3819
3820 repo.reset_to_head().unwrap();
3822
3823 let staged_after = repo.get_staged_files().unwrap();
3825 assert_eq!(
3826 staged_after.len(),
3827 0,
3828 "Should have no staged files after reset"
3829 );
3830 }
3831
3832 #[test]
3833 fn test_reset_to_head_clears_modified_files() {
3834 let (_temp_dir, repo_path) = create_test_repo();
3835 let repo = GitRepository::open(&repo_path).unwrap();
3836
3837 std::fs::write(repo_path.join("README.md"), "# Modified content").unwrap();
3839
3840 Command::new("git")
3842 .args(["add", "README.md"])
3843 .current_dir(&repo_path)
3844 .output()
3845 .unwrap();
3846
3847 assert!(repo.is_dirty().unwrap(), "Repo should be dirty");
3849
3850 repo.reset_to_head().unwrap();
3852
3853 assert!(
3855 !repo.is_dirty().unwrap(),
3856 "Repo should be clean after reset"
3857 );
3858
3859 let content = std::fs::read_to_string(repo_path.join("README.md")).unwrap();
3861 assert_eq!(
3862 content, "# Test",
3863 "File should be restored to original content"
3864 );
3865 }
3866
3867 #[test]
3868 fn test_reset_to_head_preserves_untracked_files() {
3869 let (_temp_dir, repo_path) = create_test_repo();
3870 let repo = GitRepository::open(&repo_path).unwrap();
3871
3872 std::fs::write(repo_path.join("untracked.txt"), "Untracked content").unwrap();
3874
3875 std::fs::write(repo_path.join("staged.txt"), "Staged content").unwrap();
3877 Command::new("git")
3878 .args(["add", "staged.txt"])
3879 .current_dir(&repo_path)
3880 .output()
3881 .unwrap();
3882
3883 repo.reset_to_head().unwrap();
3885
3886 assert!(
3888 repo_path.join("untracked.txt").exists(),
3889 "Untracked file should be preserved"
3890 );
3891
3892 assert!(
3894 !repo_path.join("staged.txt").exists(),
3895 "Staged but uncommitted file should be removed"
3896 );
3897 }
3898
3899 #[test]
3900 fn test_cherry_pick_does_not_modify_source() {
3901 let (_temp_dir, repo_path) = create_test_repo();
3902 let repo = GitRepository::open(&repo_path).unwrap();
3903
3904 repo.create_branch("feature", None).unwrap();
3906 repo.checkout_branch("feature").unwrap();
3907
3908 for i in 1..=3 {
3910 std::fs::write(
3911 repo_path.join(format!("file{i}.txt")),
3912 format!("Content {i}"),
3913 )
3914 .unwrap();
3915 Command::new("git")
3916 .args(["add", "."])
3917 .current_dir(&repo_path)
3918 .output()
3919 .unwrap();
3920
3921 Command::new("git")
3922 .args(["commit", "-m", &format!("Commit {i}")])
3923 .current_dir(&repo_path)
3924 .output()
3925 .unwrap();
3926 }
3927
3928 let source_commits = Command::new("git")
3930 .args(["log", "--format=%H", "feature"])
3931 .current_dir(&repo_path)
3932 .output()
3933 .unwrap();
3934 let source_state = String::from_utf8_lossy(&source_commits.stdout).to_string();
3935
3936 let commits: Vec<&str> = source_state.lines().collect();
3938 let middle_commit = commits[1];
3939
3940 Command::new("git")
3942 .args(["checkout", "-"])
3943 .current_dir(&repo_path)
3944 .output()
3945 .unwrap();
3946 repo.create_branch("target", None).unwrap();
3947 repo.checkout_branch("target").unwrap();
3948
3949 repo.cherry_pick(middle_commit).unwrap();
3950
3951 let after_commits = Command::new("git")
3953 .args(["log", "--format=%H", "feature"])
3954 .current_dir(&repo_path)
3955 .output()
3956 .unwrap();
3957 let after_state = String::from_utf8_lossy(&after_commits.stdout).to_string();
3958
3959 assert_eq!(
3960 source_state, after_state,
3961 "Source branch should be completely unchanged after cherry-pick"
3962 );
3963 }
3964
3965 #[test]
3966 fn test_detect_parent_branch() {
3967 let (_temp_dir, repo_path) = create_test_repo();
3968 let repo = GitRepository::open(&repo_path).unwrap();
3969
3970 repo.create_branch("dev123", None).unwrap();
3972 repo.checkout_branch("dev123").unwrap();
3973 create_commit(&repo_path, "Base commit on dev123", "base.txt");
3974
3975 repo.create_branch("feature-branch", None).unwrap();
3977 repo.checkout_branch("feature-branch").unwrap();
3978 create_commit(&repo_path, "Feature commit", "feature.txt");
3979
3980 let detected_parent = repo.detect_parent_branch().unwrap();
3982
3983 assert!(detected_parent.is_some(), "Should detect a parent branch");
3986
3987 let parent = detected_parent.unwrap();
3990 assert!(
3991 parent == "dev123" || parent == "main" || parent == "master",
3992 "Parent should be dev123, main, or master, got: {parent}"
3993 );
3994 }
3995}