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::{info, warn};
8
9#[derive(Debug, Clone)]
11pub struct RepositoryInfo {
12 pub path: PathBuf,
13 pub head_branch: Option<String>,
14 pub head_commit: Option<String>,
15 pub is_dirty: bool,
16 pub untracked_files: Vec<String>,
17}
18
19#[derive(Debug, Clone)]
21struct ForceBackupInfo {
22 pub backup_branch_name: String,
23 pub remote_commit_id: String,
24 #[allow(dead_code)] pub commits_that_would_be_lost: usize,
26}
27
28#[derive(Debug, Clone)]
30struct BranchDeletionSafety {
31 pub unpushed_commits: Vec<String>,
32 pub remote_tracking_branch: Option<String>,
33 pub is_merged_to_main: bool,
34 pub main_branch_name: String,
35}
36
37#[derive(Debug, Clone)]
39struct CheckoutSafety {
40 #[allow(dead_code)] pub has_uncommitted_changes: bool,
42 pub modified_files: Vec<String>,
43 pub staged_files: Vec<String>,
44 pub untracked_files: Vec<String>,
45 #[allow(dead_code)] pub stash_created: Option<String>,
47 #[allow(dead_code)] pub current_branch: Option<String>,
49}
50
51#[derive(Debug, Clone)]
53pub struct GitSslConfig {
54 pub accept_invalid_certs: bool,
55 pub ca_bundle_path: Option<String>,
56}
57
58#[derive(Debug, Clone)]
60pub struct GitStatusSummary {
61 staged_files: usize,
62 unstaged_files: usize,
63 untracked_files: usize,
64}
65
66impl GitStatusSummary {
67 pub fn is_clean(&self) -> bool {
68 self.staged_files == 0 && self.unstaged_files == 0 && self.untracked_files == 0
69 }
70
71 pub fn has_staged_changes(&self) -> bool {
72 self.staged_files > 0
73 }
74
75 pub fn has_unstaged_changes(&self) -> bool {
76 self.unstaged_files > 0
77 }
78
79 pub fn has_untracked_files(&self) -> bool {
80 self.untracked_files > 0
81 }
82
83 pub fn staged_count(&self) -> usize {
84 self.staged_files
85 }
86
87 pub fn unstaged_count(&self) -> usize {
88 self.unstaged_files
89 }
90
91 pub fn untracked_count(&self) -> usize {
92 self.untracked_files
93 }
94}
95
96pub struct GitRepository {
102 repo: Repository,
103 path: PathBuf,
104 ssl_config: Option<GitSslConfig>,
105 bitbucket_credentials: Option<BitbucketCredentials>,
106}
107
108#[derive(Debug, Clone)]
109struct BitbucketCredentials {
110 username: Option<String>,
111 token: Option<String>,
112}
113
114impl GitRepository {
115 pub fn open(path: &Path) -> Result<Self> {
118 let repo = Repository::discover(path)
119 .map_err(|e| CascadeError::config(format!("Not a git repository: {e}")))?;
120
121 let workdir = repo
122 .workdir()
123 .ok_or_else(|| CascadeError::config("Repository has no working directory"))?
124 .to_path_buf();
125
126 let ssl_config = Self::load_ssl_config_from_cascade(&workdir);
128 let bitbucket_credentials = Self::load_bitbucket_credentials_from_cascade(&workdir);
129
130 Ok(Self {
131 repo,
132 path: workdir,
133 ssl_config,
134 bitbucket_credentials,
135 })
136 }
137
138 fn load_ssl_config_from_cascade(repo_path: &Path) -> Option<GitSslConfig> {
140 let config_dir = crate::config::get_repo_config_dir(repo_path).ok()?;
142 let config_path = config_dir.join("config.json");
143 let settings = crate::config::Settings::load_from_file(&config_path).ok()?;
144
145 if settings.bitbucket.accept_invalid_certs.is_some()
147 || settings.bitbucket.ca_bundle_path.is_some()
148 {
149 Some(GitSslConfig {
150 accept_invalid_certs: settings.bitbucket.accept_invalid_certs.unwrap_or(false),
151 ca_bundle_path: settings.bitbucket.ca_bundle_path,
152 })
153 } else {
154 None
155 }
156 }
157
158 fn load_bitbucket_credentials_from_cascade(repo_path: &Path) -> Option<BitbucketCredentials> {
160 let config_dir = crate::config::get_repo_config_dir(repo_path).ok()?;
162 let config_path = config_dir.join("config.json");
163 let settings = crate::config::Settings::load_from_file(&config_path).ok()?;
164
165 if settings.bitbucket.username.is_some() || settings.bitbucket.token.is_some() {
167 Some(BitbucketCredentials {
168 username: settings.bitbucket.username.clone(),
169 token: settings.bitbucket.token.clone(),
170 })
171 } else {
172 None
173 }
174 }
175
176 pub fn get_info(&self) -> Result<RepositoryInfo> {
178 let head_branch = self.get_current_branch().ok();
179 let head_commit = self.get_head_commit_hash().ok();
180 let is_dirty = self.is_dirty()?;
181 let untracked_files = self.get_untracked_files()?;
182
183 Ok(RepositoryInfo {
184 path: self.path.clone(),
185 head_branch,
186 head_commit,
187 is_dirty,
188 untracked_files,
189 })
190 }
191
192 pub fn get_current_branch(&self) -> Result<String> {
194 let head = self
195 .repo
196 .head()
197 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
198
199 if let Some(name) = head.shorthand() {
200 Ok(name.to_string())
201 } else {
202 let commit = head
204 .peel_to_commit()
205 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))?;
206 Ok(format!("HEAD@{}", commit.id()))
207 }
208 }
209
210 pub fn get_head_commit_hash(&self) -> Result<String> {
212 let head = self
213 .repo
214 .head()
215 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
216
217 let commit = head
218 .peel_to_commit()
219 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))?;
220
221 Ok(commit.id().to_string())
222 }
223
224 pub fn is_dirty(&self) -> Result<bool> {
226 let statuses = self.repo.statuses(None).map_err(CascadeError::Git)?;
227
228 for status in statuses.iter() {
229 let flags = status.status();
230
231 if flags.intersects(
233 git2::Status::INDEX_MODIFIED
234 | git2::Status::INDEX_NEW
235 | git2::Status::INDEX_DELETED
236 | git2::Status::WT_MODIFIED
237 | git2::Status::WT_NEW
238 | git2::Status::WT_DELETED,
239 ) {
240 return Ok(true);
241 }
242 }
243
244 Ok(false)
245 }
246
247 pub fn get_untracked_files(&self) -> Result<Vec<String>> {
249 let statuses = self.repo.statuses(None).map_err(CascadeError::Git)?;
250
251 let mut untracked = Vec::new();
252 for status in statuses.iter() {
253 if status.status().contains(git2::Status::WT_NEW) {
254 if let Some(path) = status.path() {
255 untracked.push(path.to_string());
256 }
257 }
258 }
259
260 Ok(untracked)
261 }
262
263 pub fn create_branch(&self, name: &str, target: Option<&str>) -> Result<()> {
265 let target_commit = if let Some(target) = target {
266 let target_obj = self.repo.revparse_single(target).map_err(|e| {
268 CascadeError::branch(format!("Could not find target '{target}': {e}"))
269 })?;
270 target_obj.peel_to_commit().map_err(|e| {
271 CascadeError::branch(format!("Target '{target}' is not a commit: {e}"))
272 })?
273 } else {
274 let head = self
276 .repo
277 .head()
278 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
279 head.peel_to_commit()
280 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))?
281 };
282
283 self.repo
284 .branch(name, &target_commit, false)
285 .map_err(|e| CascadeError::branch(format!("Could not create branch '{name}': {e}")))?;
286
287 Ok(())
289 }
290
291 pub fn checkout_branch(&self, name: &str) -> Result<()> {
293 self.checkout_branch_with_options(name, false)
294 }
295
296 pub fn checkout_branch_unsafe(&self, name: &str) -> Result<()> {
298 self.checkout_branch_with_options(name, true)
299 }
300
301 fn checkout_branch_with_options(&self, name: &str, force_unsafe: bool) -> Result<()> {
303 info!("Attempting to checkout branch: {}", name);
304
305 if !force_unsafe {
307 let safety_result = self.check_checkout_safety(name)?;
308 if let Some(safety_info) = safety_result {
309 self.handle_checkout_confirmation(name, &safety_info)?;
311 }
312 }
313
314 let branch = self
316 .repo
317 .find_branch(name, git2::BranchType::Local)
318 .map_err(|e| CascadeError::branch(format!("Could not find branch '{name}': {e}")))?;
319
320 let branch_ref = branch.get();
321 let tree = branch_ref.peel_to_tree().map_err(|e| {
322 CascadeError::branch(format!("Could not get tree for branch '{name}': {e}"))
323 })?;
324
325 self.repo
327 .checkout_tree(tree.as_object(), None)
328 .map_err(|e| {
329 CascadeError::branch(format!("Could not checkout branch '{name}': {e}"))
330 })?;
331
332 self.repo
334 .set_head(&format!("refs/heads/{name}"))
335 .map_err(|e| CascadeError::branch(format!("Could not update HEAD to '{name}': {e}")))?;
336
337 Output::success(format!("Switched to branch '{name}'"));
338 Ok(())
339 }
340
341 pub fn checkout_commit(&self, commit_hash: &str) -> Result<()> {
343 self.checkout_commit_with_options(commit_hash, false)
344 }
345
346 pub fn checkout_commit_unsafe(&self, commit_hash: &str) -> Result<()> {
348 self.checkout_commit_with_options(commit_hash, true)
349 }
350
351 fn checkout_commit_with_options(&self, commit_hash: &str, force_unsafe: bool) -> Result<()> {
353 info!("Attempting to checkout commit: {}", commit_hash);
354
355 if !force_unsafe {
357 let safety_result = self.check_checkout_safety(&format!("commit:{commit_hash}"))?;
358 if let Some(safety_info) = safety_result {
359 self.handle_checkout_confirmation(&format!("commit {commit_hash}"), &safety_info)?;
361 }
362 }
363
364 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
365
366 let commit = self.repo.find_commit(oid).map_err(|e| {
367 CascadeError::branch(format!("Could not find commit '{commit_hash}': {e}"))
368 })?;
369
370 let tree = commit.tree().map_err(|e| {
371 CascadeError::branch(format!(
372 "Could not get tree for commit '{commit_hash}': {e}"
373 ))
374 })?;
375
376 self.repo
378 .checkout_tree(tree.as_object(), None)
379 .map_err(|e| {
380 CascadeError::branch(format!("Could not checkout commit '{commit_hash}': {e}"))
381 })?;
382
383 self.repo.set_head_detached(oid).map_err(|e| {
385 CascadeError::branch(format!(
386 "Could not update HEAD to commit '{commit_hash}': {e}"
387 ))
388 })?;
389
390 Output::success(format!(
391 "Checked out commit '{commit_hash}' (detached HEAD)"
392 ));
393 Ok(())
394 }
395
396 pub fn branch_exists(&self, name: &str) -> bool {
398 self.repo.find_branch(name, git2::BranchType::Local).is_ok()
399 }
400
401 pub fn branch_exists_or_fetch(&self, name: &str) -> Result<bool> {
403 if self.repo.find_branch(name, git2::BranchType::Local).is_ok() {
405 return Ok(true);
406 }
407
408 println!("🔍 Branch '{name}' not found locally, trying to fetch from remote...");
410
411 use std::process::Command;
412
413 let fetch_result = Command::new("git")
415 .args(["fetch", "origin", &format!("{name}:{name}")])
416 .current_dir(&self.path)
417 .output();
418
419 match fetch_result {
420 Ok(output) => {
421 if output.status.success() {
422 println!("✅ Successfully fetched '{name}' from origin");
423 return Ok(self.repo.find_branch(name, git2::BranchType::Local).is_ok());
425 } else {
426 let stderr = String::from_utf8_lossy(&output.stderr);
427 tracing::debug!("Failed to fetch branch '{name}': {stderr}");
428 }
429 }
430 Err(e) => {
431 tracing::debug!("Git fetch command failed: {e}");
432 }
433 }
434
435 if name.contains('/') {
437 println!("🔍 Trying alternative fetch patterns...");
438
439 let fetch_all_result = Command::new("git")
441 .args(["fetch", "origin"])
442 .current_dir(&self.path)
443 .output();
444
445 if let Ok(output) = fetch_all_result {
446 if output.status.success() {
447 let checkout_result = Command::new("git")
449 .args(["checkout", "-b", name, &format!("origin/{name}")])
450 .current_dir(&self.path)
451 .output();
452
453 if let Ok(checkout_output) = checkout_result {
454 if checkout_output.status.success() {
455 println!(
456 "✅ Successfully created local branch '{name}' from origin/{name}"
457 );
458 return Ok(true);
459 }
460 }
461 }
462 }
463 }
464
465 Ok(false)
467 }
468
469 pub fn get_branch_commit_hash(&self, branch_name: &str) -> Result<String> {
471 let branch = self
472 .repo
473 .find_branch(branch_name, git2::BranchType::Local)
474 .map_err(|e| {
475 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
476 })?;
477
478 let commit = branch.get().peel_to_commit().map_err(|e| {
479 CascadeError::branch(format!(
480 "Could not get commit for branch '{branch_name}': {e}"
481 ))
482 })?;
483
484 Ok(commit.id().to_string())
485 }
486
487 pub fn list_branches(&self) -> Result<Vec<String>> {
489 let branches = self
490 .repo
491 .branches(Some(git2::BranchType::Local))
492 .map_err(CascadeError::Git)?;
493
494 let mut branch_names = Vec::new();
495 for branch in branches {
496 let (branch, _) = branch.map_err(CascadeError::Git)?;
497 if let Some(name) = branch.name().map_err(CascadeError::Git)? {
498 branch_names.push(name.to_string());
499 }
500 }
501
502 Ok(branch_names)
503 }
504
505 pub fn get_upstream_branch(&self, branch_name: &str) -> Result<Option<String>> {
507 let config = self.repo.config().map_err(CascadeError::Git)?;
509
510 let remote_key = format!("branch.{branch_name}.remote");
512 let merge_key = format!("branch.{branch_name}.merge");
513
514 if let (Ok(remote), Ok(merge_ref)) = (
515 config.get_string(&remote_key),
516 config.get_string(&merge_key),
517 ) {
518 if let Some(branch_part) = merge_ref.strip_prefix("refs/heads/") {
520 return Ok(Some(format!("{remote}/{branch_part}")));
521 }
522 }
523
524 let potential_upstream = format!("origin/{branch_name}");
526 if self
527 .repo
528 .find_reference(&format!("refs/remotes/{potential_upstream}"))
529 .is_ok()
530 {
531 return Ok(Some(potential_upstream));
532 }
533
534 Ok(None)
535 }
536
537 pub fn get_ahead_behind_counts(
539 &self,
540 local_branch: &str,
541 upstream_branch: &str,
542 ) -> Result<(usize, usize)> {
543 let local_ref = self
545 .repo
546 .find_reference(&format!("refs/heads/{local_branch}"))
547 .map_err(|_| {
548 CascadeError::config(format!("Local branch '{local_branch}' not found"))
549 })?;
550 let local_commit = local_ref.peel_to_commit().map_err(CascadeError::Git)?;
551
552 let upstream_ref = self
553 .repo
554 .find_reference(&format!("refs/remotes/{upstream_branch}"))
555 .map_err(|_| {
556 CascadeError::config(format!("Upstream branch '{upstream_branch}' not found"))
557 })?;
558 let upstream_commit = upstream_ref.peel_to_commit().map_err(CascadeError::Git)?;
559
560 let (ahead, behind) = self
562 .repo
563 .graph_ahead_behind(local_commit.id(), upstream_commit.id())
564 .map_err(CascadeError::Git)?;
565
566 Ok((ahead, behind))
567 }
568
569 pub fn set_upstream(&self, branch_name: &str, remote: &str, remote_branch: &str) -> Result<()> {
571 let mut config = self.repo.config().map_err(CascadeError::Git)?;
572
573 let remote_key = format!("branch.{branch_name}.remote");
575 config
576 .set_str(&remote_key, remote)
577 .map_err(CascadeError::Git)?;
578
579 let merge_key = format!("branch.{branch_name}.merge");
581 let merge_value = format!("refs/heads/{remote_branch}");
582 config
583 .set_str(&merge_key, &merge_value)
584 .map_err(CascadeError::Git)?;
585
586 Ok(())
587 }
588
589 pub fn commit(&self, message: &str) -> Result<String> {
591 self.validate_git_user_config()?;
593
594 let signature = self.get_signature()?;
595 let tree_id = self.get_index_tree()?;
596 let tree = self.repo.find_tree(tree_id).map_err(CascadeError::Git)?;
597
598 let head = self.repo.head().map_err(CascadeError::Git)?;
600 let parent_commit = head.peel_to_commit().map_err(CascadeError::Git)?;
601
602 let commit_id = self
603 .repo
604 .commit(
605 Some("HEAD"),
606 &signature,
607 &signature,
608 message,
609 &tree,
610 &[&parent_commit],
611 )
612 .map_err(CascadeError::Git)?;
613
614 Output::success(format!("Created commit: {commit_id} - {message}"));
615 Ok(commit_id.to_string())
616 }
617
618 pub fn commit_staged_changes(&self, default_message: &str) -> Result<Option<String>> {
620 let staged_files = self.get_staged_files()?;
622 if staged_files.is_empty() {
623 tracing::debug!("No staged changes to commit");
624 return Ok(None);
625 }
626
627 tracing::info!("Committing {} staged files", staged_files.len());
628 let commit_hash = self.commit(default_message)?;
629 Ok(Some(commit_hash))
630 }
631
632 pub fn stage_all(&self) -> Result<()> {
634 let mut index = self.repo.index().map_err(CascadeError::Git)?;
635
636 index
637 .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
638 .map_err(CascadeError::Git)?;
639
640 index.write().map_err(CascadeError::Git)?;
641
642 tracing::debug!("Staged all changes");
643 Ok(())
644 }
645
646 pub fn stage_files(&self, file_paths: &[&str]) -> Result<()> {
648 if file_paths.is_empty() {
649 tracing::debug!("No files to stage");
650 return Ok(());
651 }
652
653 let mut index = self.repo.index().map_err(CascadeError::Git)?;
654
655 for file_path in file_paths {
656 index
657 .add_path(std::path::Path::new(file_path))
658 .map_err(CascadeError::Git)?;
659 }
660
661 index.write().map_err(CascadeError::Git)?;
662
663 tracing::debug!(
664 "Staged {} specific files: {:?}",
665 file_paths.len(),
666 file_paths
667 );
668 Ok(())
669 }
670
671 pub fn stage_conflict_resolved_files(&self) -> Result<()> {
673 let conflicted_files = self.get_conflicted_files()?;
674 if conflicted_files.is_empty() {
675 tracing::debug!("No conflicted files to stage");
676 return Ok(());
677 }
678
679 let file_paths: Vec<&str> = conflicted_files.iter().map(|s| s.as_str()).collect();
680 self.stage_files(&file_paths)?;
681
682 tracing::debug!("Staged {} conflict-resolved files", conflicted_files.len());
683 Ok(())
684 }
685
686 pub fn path(&self) -> &Path {
688 &self.path
689 }
690
691 pub fn commit_exists(&self, commit_hash: &str) -> Result<bool> {
693 match Oid::from_str(commit_hash) {
694 Ok(oid) => match self.repo.find_commit(oid) {
695 Ok(_) => Ok(true),
696 Err(_) => Ok(false),
697 },
698 Err(_) => Ok(false),
699 }
700 }
701
702 pub fn get_head_commit(&self) -> Result<git2::Commit<'_>> {
704 let head = self
705 .repo
706 .head()
707 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
708 head.peel_to_commit()
709 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))
710 }
711
712 pub fn get_commit(&self, commit_hash: &str) -> Result<git2::Commit<'_>> {
714 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
715
716 self.repo.find_commit(oid).map_err(CascadeError::Git)
717 }
718
719 pub fn get_branch_head(&self, branch_name: &str) -> Result<String> {
721 let branch = self
722 .repo
723 .find_branch(branch_name, git2::BranchType::Local)
724 .map_err(|e| {
725 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
726 })?;
727
728 let commit = branch.get().peel_to_commit().map_err(|e| {
729 CascadeError::branch(format!(
730 "Could not get commit for branch '{branch_name}': {e}"
731 ))
732 })?;
733
734 Ok(commit.id().to_string())
735 }
736
737 pub fn validate_git_user_config(&self) -> Result<()> {
739 if let Ok(config) = self.repo.config() {
740 let name_result = config.get_string("user.name");
741 let email_result = config.get_string("user.email");
742
743 if let (Ok(name), Ok(email)) = (name_result, email_result) {
744 if !name.trim().is_empty() && !email.trim().is_empty() {
745 tracing::debug!("Git user config validated: {} <{}>", name, email);
746 return Ok(());
747 }
748 }
749 }
750
751 let is_ci = std::env::var("CI").is_ok();
753
754 if is_ci {
755 tracing::debug!("CI environment - skipping git user config validation");
756 return Ok(());
757 }
758
759 Output::warning("Git user configuration missing or incomplete");
760 Output::info("This can cause cherry-pick and commit operations to fail");
761 Output::info("Please configure git user information:");
762 Output::bullet("git config user.name \"Your Name\"".to_string());
763 Output::bullet("git config user.email \"your.email@example.com\"".to_string());
764 Output::info("Or set globally with the --global flag");
765
766 Ok(())
769 }
770
771 fn get_signature(&self) -> Result<Signature<'_>> {
773 if let Ok(config) = self.repo.config() {
775 let name_result = config.get_string("user.name");
777 let email_result = config.get_string("user.email");
778
779 if let (Ok(name), Ok(email)) = (name_result, email_result) {
780 if !name.trim().is_empty() && !email.trim().is_empty() {
781 tracing::debug!("Using git config: {} <{}>", name, email);
782 return Signature::now(&name, &email).map_err(CascadeError::Git);
783 }
784 } else {
785 tracing::debug!("Git user config incomplete or missing");
786 }
787 }
788
789 let is_ci = std::env::var("CI").is_ok();
791
792 if is_ci {
793 tracing::debug!("CI environment detected, using fallback signature");
794 return Signature::now("Cascade CLI", "cascade@example.com").map_err(CascadeError::Git);
795 }
796
797 tracing::warn!("Git user configuration missing - this can cause commit operations to fail");
799
800 match Signature::now("Cascade CLI", "cascade@example.com") {
802 Ok(sig) => {
803 Output::warning("Git user not configured - using fallback signature");
804 Output::info("For better git history, run:");
805 Output::bullet("git config user.name \"Your Name\"".to_string());
806 Output::bullet("git config user.email \"your.email@example.com\"".to_string());
807 Output::info("Or set it globally with --global flag");
808 Ok(sig)
809 }
810 Err(e) => {
811 Err(CascadeError::branch(format!(
812 "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\""
813 )))
814 }
815 }
816 }
817
818 fn configure_remote_callbacks(&self) -> Result<git2::RemoteCallbacks<'_>> {
821 self.configure_remote_callbacks_with_fallback(false)
822 }
823
824 fn should_retry_with_default_credentials(&self, error: &git2::Error) -> bool {
826 match error.class() {
827 git2::ErrorClass::Http => {
829 match error.code() {
831 git2::ErrorCode::Auth => true,
832 _ => {
833 let error_string = error.to_string();
835 error_string.contains("too many redirects")
836 || error_string.contains("authentication replays")
837 || error_string.contains("authentication required")
838 }
839 }
840 }
841 git2::ErrorClass::Net => {
842 let error_string = error.to_string();
844 error_string.contains("authentication")
845 || error_string.contains("unauthorized")
846 || error_string.contains("forbidden")
847 }
848 _ => false,
849 }
850 }
851
852 fn should_fallback_to_git_cli(&self, error: &git2::Error) -> bool {
854 match error.class() {
855 git2::ErrorClass::Ssl => true,
857
858 git2::ErrorClass::Http if error.code() == git2::ErrorCode::Certificate => true,
860
861 git2::ErrorClass::Ssh => {
863 let error_string = error.to_string();
864 error_string.contains("no callback set")
865 || error_string.contains("authentication required")
866 }
867
868 git2::ErrorClass::Net => {
870 let error_string = error.to_string();
871 error_string.contains("TLS stream")
872 || error_string.contains("SSL")
873 || error_string.contains("proxy")
874 || error_string.contains("firewall")
875 }
876
877 git2::ErrorClass::Http => {
879 let error_string = error.to_string();
880 error_string.contains("TLS stream")
881 || error_string.contains("SSL")
882 || error_string.contains("proxy")
883 }
884
885 _ => false,
886 }
887 }
888
889 fn configure_remote_callbacks_with_fallback(
890 &self,
891 use_default_first: bool,
892 ) -> Result<git2::RemoteCallbacks<'_>> {
893 let mut callbacks = git2::RemoteCallbacks::new();
894
895 let bitbucket_credentials = self.bitbucket_credentials.clone();
897 callbacks.credentials(move |url, username_from_url, allowed_types| {
898 tracing::debug!(
899 "Authentication requested for URL: {}, username: {:?}, allowed_types: {:?}",
900 url,
901 username_from_url,
902 allowed_types
903 );
904
905 if allowed_types.contains(git2::CredentialType::SSH_KEY) {
907 if let Some(username) = username_from_url {
908 tracing::debug!("Trying SSH key authentication for user: {}", username);
909 return git2::Cred::ssh_key_from_agent(username);
910 }
911 }
912
913 if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
915 if use_default_first {
917 tracing::debug!("Corporate network mode: trying DefaultCredentials first");
918 return git2::Cred::default();
919 }
920
921 if url.contains("bitbucket") {
922 if let Some(creds) = &bitbucket_credentials {
923 if let (Some(username), Some(token)) = (&creds.username, &creds.token) {
925 tracing::debug!("Trying Bitbucket username + token authentication");
926 return git2::Cred::userpass_plaintext(username, token);
927 }
928
929 if let Some(token) = &creds.token {
931 tracing::debug!("Trying Bitbucket token-as-username authentication");
932 return git2::Cred::userpass_plaintext(token, "");
933 }
934
935 if let Some(username) = &creds.username {
937 tracing::debug!("Trying Bitbucket username authentication (will use credential helper)");
938 return git2::Cred::username(username);
939 }
940 }
941 }
942
943 tracing::debug!("Trying default credential helper for HTTPS authentication");
945 return git2::Cred::default();
946 }
947
948 tracing::debug!("Using default credential fallback");
950 git2::Cred::default()
951 });
952
953 let mut ssl_configured = false;
958
959 if let Some(ssl_config) = &self.ssl_config {
961 if ssl_config.accept_invalid_certs {
962 Output::warning(
963 "SSL certificate verification DISABLED via Cascade config - this is insecure!",
964 );
965 callbacks.certificate_check(|_cert, _host| {
966 tracing::debug!("⚠️ Accepting invalid certificate for host: {}", _host);
967 Ok(git2::CertificateCheckStatus::CertificateOk)
968 });
969 ssl_configured = true;
970 } else if let Some(ca_path) = &ssl_config.ca_bundle_path {
971 Output::info(format!(
972 "Using custom CA bundle from Cascade config: {ca_path}"
973 ));
974 callbacks.certificate_check(|_cert, host| {
975 tracing::debug!("Using custom CA bundle for host: {}", host);
976 Ok(git2::CertificateCheckStatus::CertificateOk)
977 });
978 ssl_configured = true;
979 }
980 }
981
982 if !ssl_configured {
984 if let Ok(config) = self.repo.config() {
985 let ssl_verify = config.get_bool("http.sslVerify").unwrap_or(true);
986
987 if !ssl_verify {
988 Output::warning(
989 "SSL certificate verification DISABLED via git config - this is insecure!",
990 );
991 callbacks.certificate_check(|_cert, host| {
992 tracing::debug!("⚠️ Bypassing SSL verification for host: {}", host);
993 Ok(git2::CertificateCheckStatus::CertificateOk)
994 });
995 ssl_configured = true;
996 } else if let Ok(ca_path) = config.get_string("http.sslCAInfo") {
997 Output::info(format!("Using custom CA bundle from git config: {ca_path}"));
998 callbacks.certificate_check(|_cert, host| {
999 tracing::debug!("Using git config CA bundle for host: {}", host);
1000 Ok(git2::CertificateCheckStatus::CertificateOk)
1001 });
1002 ssl_configured = true;
1003 }
1004 }
1005 }
1006
1007 if !ssl_configured {
1010 tracing::debug!(
1011 "Using system certificate store for SSL verification (default behavior)"
1012 );
1013
1014 if cfg!(target_os = "macos") {
1016 tracing::debug!("macOS detected - using default certificate validation");
1017 } else {
1020 callbacks.certificate_check(|_cert, host| {
1022 tracing::debug!("System certificate validation for host: {}", host);
1023 Ok(git2::CertificateCheckStatus::CertificatePassthrough)
1024 });
1025 }
1026 }
1027
1028 Ok(callbacks)
1029 }
1030
1031 fn get_index_tree(&self) -> Result<Oid> {
1033 let mut index = self.repo.index().map_err(CascadeError::Git)?;
1034
1035 index.write_tree().map_err(CascadeError::Git)
1036 }
1037
1038 pub fn get_status(&self) -> Result<git2::Statuses<'_>> {
1040 self.repo.statuses(None).map_err(CascadeError::Git)
1041 }
1042
1043 pub fn get_status_summary(&self) -> Result<GitStatusSummary> {
1045 let statuses = self.get_status()?;
1046
1047 let mut staged_files = 0;
1048 let mut unstaged_files = 0;
1049 let mut untracked_files = 0;
1050
1051 for status in statuses.iter() {
1052 let flags = status.status();
1053
1054 if flags.intersects(
1055 git2::Status::INDEX_MODIFIED
1056 | git2::Status::INDEX_NEW
1057 | git2::Status::INDEX_DELETED
1058 | git2::Status::INDEX_RENAMED
1059 | git2::Status::INDEX_TYPECHANGE,
1060 ) {
1061 staged_files += 1;
1062 }
1063
1064 if flags.intersects(
1065 git2::Status::WT_MODIFIED
1066 | git2::Status::WT_DELETED
1067 | git2::Status::WT_TYPECHANGE
1068 | git2::Status::WT_RENAMED,
1069 ) {
1070 unstaged_files += 1;
1071 }
1072
1073 if flags.intersects(git2::Status::WT_NEW) {
1074 untracked_files += 1;
1075 }
1076 }
1077
1078 Ok(GitStatusSummary {
1079 staged_files,
1080 unstaged_files,
1081 untracked_files,
1082 })
1083 }
1084
1085 pub fn get_current_commit_hash(&self) -> Result<String> {
1087 self.get_head_commit_hash()
1088 }
1089
1090 pub fn get_commit_count_between(&self, from_commit: &str, to_commit: &str) -> Result<usize> {
1092 let from_oid = git2::Oid::from_str(from_commit).map_err(CascadeError::Git)?;
1093 let to_oid = git2::Oid::from_str(to_commit).map_err(CascadeError::Git)?;
1094
1095 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
1096 revwalk.push(to_oid).map_err(CascadeError::Git)?;
1097 revwalk.hide(from_oid).map_err(CascadeError::Git)?;
1098
1099 Ok(revwalk.count())
1100 }
1101
1102 pub fn get_remote_url(&self, name: &str) -> Result<String> {
1104 let remote = self.repo.find_remote(name).map_err(CascadeError::Git)?;
1105 Ok(remote.url().unwrap_or("unknown").to_string())
1106 }
1107
1108 pub fn cherry_pick(&self, commit_hash: &str) -> Result<String> {
1110 tracing::debug!("Cherry-picking commit {}", commit_hash);
1111
1112 self.validate_git_user_config()?;
1114
1115 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
1116 let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
1117
1118 let commit_tree = commit.tree().map_err(CascadeError::Git)?;
1120
1121 let parent_commit = if commit.parent_count() > 0 {
1123 commit.parent(0).map_err(CascadeError::Git)?
1124 } else {
1125 let empty_tree_oid = self.repo.treebuilder(None)?.write()?;
1127 let empty_tree = self.repo.find_tree(empty_tree_oid)?;
1128 let sig = self.get_signature()?;
1129 return self
1130 .repo
1131 .commit(
1132 Some("HEAD"),
1133 &sig,
1134 &sig,
1135 commit.message().unwrap_or("Cherry-picked commit"),
1136 &empty_tree,
1137 &[],
1138 )
1139 .map(|oid| oid.to_string())
1140 .map_err(CascadeError::Git);
1141 };
1142
1143 let parent_tree = parent_commit.tree().map_err(CascadeError::Git)?;
1144
1145 let head_commit = self.get_head_commit()?;
1147 let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
1148
1149 let mut index = self
1151 .repo
1152 .merge_trees(&parent_tree, &head_tree, &commit_tree, None)
1153 .map_err(CascadeError::Git)?;
1154
1155 if index.has_conflicts() {
1157 return Err(CascadeError::branch(format!(
1158 "Cherry-pick of {commit_hash} has conflicts that need manual resolution"
1159 )));
1160 }
1161
1162 let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
1164 let merged_tree = self
1165 .repo
1166 .find_tree(merged_tree_oid)
1167 .map_err(CascadeError::Git)?;
1168
1169 let signature = self.get_signature()?;
1171 let message = format!("Cherry-pick: {}", commit.message().unwrap_or(""));
1172
1173 let new_commit_oid = self
1174 .repo
1175 .commit(
1176 Some("HEAD"),
1177 &signature,
1178 &signature,
1179 &message,
1180 &merged_tree,
1181 &[&head_commit],
1182 )
1183 .map_err(CascadeError::Git)?;
1184
1185 let new_commit = self
1187 .repo
1188 .find_commit(new_commit_oid)
1189 .map_err(CascadeError::Git)?;
1190 let new_tree = new_commit.tree().map_err(CascadeError::Git)?;
1191
1192 self.repo
1193 .checkout_tree(
1194 new_tree.as_object(),
1195 Some(git2::build::CheckoutBuilder::new().force()),
1196 )
1197 .map_err(CascadeError::Git)?;
1198
1199 tracing::info!("Cherry-picked {} -> {}", commit_hash, new_commit_oid);
1200 Ok(new_commit_oid.to_string())
1201 }
1202
1203 pub fn has_conflicts(&self) -> Result<bool> {
1205 let index = self.repo.index().map_err(CascadeError::Git)?;
1206 Ok(index.has_conflicts())
1207 }
1208
1209 pub fn get_conflicted_files(&self) -> Result<Vec<String>> {
1211 let index = self.repo.index().map_err(CascadeError::Git)?;
1212
1213 let mut conflicts = Vec::new();
1214
1215 let conflict_iter = index.conflicts().map_err(CascadeError::Git)?;
1217
1218 for conflict in conflict_iter {
1219 let conflict = conflict.map_err(CascadeError::Git)?;
1220 if let Some(our) = conflict.our {
1221 if let Ok(path) = std::str::from_utf8(&our.path) {
1222 conflicts.push(path.to_string());
1223 }
1224 } else if let Some(their) = conflict.their {
1225 if let Ok(path) = std::str::from_utf8(&their.path) {
1226 conflicts.push(path.to_string());
1227 }
1228 }
1229 }
1230
1231 Ok(conflicts)
1232 }
1233
1234 pub fn fetch(&self) -> Result<()> {
1236 tracing::info!("Fetching from origin");
1237
1238 let mut remote = self
1239 .repo
1240 .find_remote("origin")
1241 .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
1242
1243 let callbacks = self.configure_remote_callbacks()?;
1245
1246 let mut fetch_options = git2::FetchOptions::new();
1248 fetch_options.remote_callbacks(callbacks);
1249
1250 match remote.fetch::<&str>(&[], Some(&mut fetch_options), None) {
1252 Ok(_) => {
1253 tracing::debug!("Fetch completed successfully");
1254 Ok(())
1255 }
1256 Err(e) => {
1257 if self.should_retry_with_default_credentials(&e) {
1258 tracing::debug!(
1259 "Authentication error detected (class: {:?}, code: {:?}): {}, retrying with DefaultCredentials",
1260 e.class(), e.code(), e
1261 );
1262
1263 let callbacks = self.configure_remote_callbacks_with_fallback(true)?;
1265 let mut fetch_options = git2::FetchOptions::new();
1266 fetch_options.remote_callbacks(callbacks);
1267
1268 match remote.fetch::<&str>(&[], Some(&mut fetch_options), None) {
1269 Ok(_) => {
1270 tracing::debug!("Fetch succeeded with DefaultCredentials");
1271 return Ok(());
1272 }
1273 Err(retry_error) => {
1274 tracing::debug!(
1275 "DefaultCredentials retry failed: {}, falling back to git CLI",
1276 retry_error
1277 );
1278 return self.fetch_with_git_cli();
1279 }
1280 }
1281 }
1282
1283 if self.should_fallback_to_git_cli(&e) {
1284 tracing::debug!(
1285 "Network/SSL error detected (class: {:?}, code: {:?}): {}, falling back to git CLI for fetch operation",
1286 e.class(), e.code(), e
1287 );
1288 return self.fetch_with_git_cli();
1289 }
1290 Err(CascadeError::Git(e))
1291 }
1292 }
1293 }
1294
1295 pub fn pull(&self, branch: &str) -> Result<()> {
1297 tracing::info!("Pulling branch: {}", branch);
1298
1299 match self.fetch() {
1301 Ok(_) => {}
1302 Err(e) => {
1303 let error_string = e.to_string();
1305 if error_string.contains("TLS stream") || error_string.contains("SSL") {
1306 tracing::warn!(
1307 "git2 error detected: {}, falling back to git CLI for pull operation",
1308 e
1309 );
1310 return self.pull_with_git_cli(branch);
1311 }
1312 return Err(e);
1313 }
1314 }
1315
1316 let remote_branch_name = format!("origin/{branch}");
1318 let remote_oid = self
1319 .repo
1320 .refname_to_id(&format!("refs/remotes/{remote_branch_name}"))
1321 .map_err(|e| {
1322 CascadeError::branch(format!("Remote branch {remote_branch_name} not found: {e}"))
1323 })?;
1324
1325 let remote_commit = self
1326 .repo
1327 .find_commit(remote_oid)
1328 .map_err(CascadeError::Git)?;
1329
1330 let head_commit = self.get_head_commit()?;
1332
1333 if head_commit.id() == remote_commit.id() {
1335 tracing::debug!("Already up to date");
1336 return Ok(());
1337 }
1338
1339 let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
1341 let remote_tree = remote_commit.tree().map_err(CascadeError::Git)?;
1342
1343 let merge_base_oid = self
1345 .repo
1346 .merge_base(head_commit.id(), remote_commit.id())
1347 .map_err(CascadeError::Git)?;
1348 let merge_base_commit = self
1349 .repo
1350 .find_commit(merge_base_oid)
1351 .map_err(CascadeError::Git)?;
1352 let merge_base_tree = merge_base_commit.tree().map_err(CascadeError::Git)?;
1353
1354 let mut index = self
1356 .repo
1357 .merge_trees(&merge_base_tree, &head_tree, &remote_tree, None)
1358 .map_err(CascadeError::Git)?;
1359
1360 if index.has_conflicts() {
1361 return Err(CascadeError::branch(
1362 "Pull has conflicts that need manual resolution".to_string(),
1363 ));
1364 }
1365
1366 let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
1368 let merged_tree = self
1369 .repo
1370 .find_tree(merged_tree_oid)
1371 .map_err(CascadeError::Git)?;
1372
1373 let signature = self.get_signature()?;
1374 let message = format!("Merge branch '{branch}' from origin");
1375
1376 self.repo
1377 .commit(
1378 Some("HEAD"),
1379 &signature,
1380 &signature,
1381 &message,
1382 &merged_tree,
1383 &[&head_commit, &remote_commit],
1384 )
1385 .map_err(CascadeError::Git)?;
1386
1387 tracing::info!("Pull completed successfully");
1388 Ok(())
1389 }
1390
1391 pub fn push(&self, branch: &str) -> Result<()> {
1393 let mut remote = self
1396 .repo
1397 .find_remote("origin")
1398 .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
1399
1400 let remote_url = remote.url().unwrap_or("unknown").to_string();
1401 tracing::debug!("Remote URL: {}", remote_url);
1402
1403 let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
1404 tracing::debug!("Push refspec: {}", refspec);
1405
1406 let mut callbacks = self.configure_remote_callbacks()?;
1408
1409 callbacks.push_update_reference(|refname, status| {
1411 if let Some(msg) = status {
1412 tracing::error!("Push failed for ref {}: {}", refname, msg);
1413 return Err(git2::Error::from_str(&format!("Push failed: {msg}")));
1414 }
1415 tracing::debug!("Push succeeded for ref: {}", refname);
1416 Ok(())
1417 });
1418
1419 let mut push_options = git2::PushOptions::new();
1421 push_options.remote_callbacks(callbacks);
1422
1423 match remote.push(&[&refspec], Some(&mut push_options)) {
1425 Ok(_) => {
1426 tracing::info!("Push completed successfully for branch: {}", branch);
1427 Ok(())
1428 }
1429 Err(e) => {
1430 tracing::debug!(
1431 "git2 push error: {} (class: {:?}, code: {:?})",
1432 e,
1433 e.class(),
1434 e.code()
1435 );
1436
1437 if self.should_retry_with_default_credentials(&e) {
1438 tracing::debug!(
1439 "Authentication error detected (class: {:?}, code: {:?}): {}, retrying with DefaultCredentials",
1440 e.class(), e.code(), e
1441 );
1442
1443 let callbacks = self.configure_remote_callbacks_with_fallback(true)?;
1445 let mut push_options = git2::PushOptions::new();
1446 push_options.remote_callbacks(callbacks);
1447
1448 match remote.push(&[&refspec], Some(&mut push_options)) {
1449 Ok(_) => {
1450 tracing::debug!("Push succeeded with DefaultCredentials");
1451 return Ok(());
1452 }
1453 Err(retry_error) => {
1454 tracing::debug!(
1455 "DefaultCredentials retry failed: {}, falling back to git CLI",
1456 retry_error
1457 );
1458 return self.push_with_git_cli(branch);
1459 }
1460 }
1461 }
1462
1463 if self.should_fallback_to_git_cli(&e) {
1464 tracing::debug!(
1465 "Network/SSL error detected (class: {:?}, code: {:?}): {}, falling back to git CLI for push operation",
1466 e.class(), e.code(), e
1467 );
1468 return self.push_with_git_cli(branch);
1469 }
1470
1471 let error_msg = if e.to_string().contains("authentication") {
1473 format!(
1474 "Authentication failed for branch '{branch}'. Try: git push origin {branch}"
1475 )
1476 } else {
1477 format!("Failed to push branch '{branch}': {e}")
1478 };
1479
1480 tracing::error!("{}", error_msg);
1481 Err(CascadeError::branch(error_msg))
1482 }
1483 }
1484 }
1485
1486 fn push_with_git_cli(&self, branch: &str) -> Result<()> {
1489 let output = std::process::Command::new("git")
1490 .args(["push", "origin", branch])
1491 .current_dir(&self.path)
1492 .output()
1493 .map_err(|e| CascadeError::branch(format!("Failed to execute git command: {e}")))?;
1494
1495 if output.status.success() {
1496 Ok(())
1498 } else {
1499 let stderr = String::from_utf8_lossy(&output.stderr);
1500 let _stdout = String::from_utf8_lossy(&output.stdout);
1501 let error_msg = if stderr.contains("SSL_connect") || stderr.contains("SSL_ERROR") {
1503 "Network error: Unable to connect to repository (VPN may be required)".to_string()
1504 } else if stderr.contains("repository") && stderr.contains("not found") {
1505 "Repository not found - check your Bitbucket configuration".to_string()
1506 } else if stderr.contains("authentication") || stderr.contains("403") {
1507 "Authentication failed - check your credentials".to_string()
1508 } else {
1509 stderr.trim().to_string()
1511 };
1512 tracing::error!("{}", error_msg);
1513 Err(CascadeError::branch(error_msg))
1514 }
1515 }
1516
1517 fn fetch_with_git_cli(&self) -> Result<()> {
1520 tracing::info!("Using git CLI fallback for fetch operation");
1521
1522 let output = std::process::Command::new("git")
1523 .args(["fetch", "origin"])
1524 .current_dir(&self.path)
1525 .output()
1526 .map_err(|e| {
1527 CascadeError::Git(git2::Error::from_str(&format!(
1528 "Failed to execute git command: {e}"
1529 )))
1530 })?;
1531
1532 if output.status.success() {
1533 tracing::info!("✅ Git CLI fetch succeeded");
1534 Ok(())
1535 } else {
1536 let stderr = String::from_utf8_lossy(&output.stderr);
1537 let stdout = String::from_utf8_lossy(&output.stdout);
1538 let error_msg = format!(
1539 "Git CLI fetch failed: {}\nStdout: {}\nStderr: {}",
1540 output.status, stdout, stderr
1541 );
1542 tracing::error!("{}", error_msg);
1543 Err(CascadeError::Git(git2::Error::from_str(&error_msg)))
1544 }
1545 }
1546
1547 fn pull_with_git_cli(&self, branch: &str) -> Result<()> {
1550 tracing::info!("Using git CLI fallback for pull operation: {}", branch);
1551
1552 let output = std::process::Command::new("git")
1553 .args(["pull", "origin", branch])
1554 .current_dir(&self.path)
1555 .output()
1556 .map_err(|e| {
1557 CascadeError::Git(git2::Error::from_str(&format!(
1558 "Failed to execute git command: {e}"
1559 )))
1560 })?;
1561
1562 if output.status.success() {
1563 tracing::info!("✅ Git CLI pull succeeded for branch: {}", branch);
1564 Ok(())
1565 } else {
1566 let stderr = String::from_utf8_lossy(&output.stderr);
1567 let stdout = String::from_utf8_lossy(&output.stdout);
1568 let error_msg = format!(
1569 "Git CLI pull failed for branch '{}': {}\nStdout: {}\nStderr: {}",
1570 branch, output.status, stdout, stderr
1571 );
1572 tracing::error!("{}", error_msg);
1573 Err(CascadeError::Git(git2::Error::from_str(&error_msg)))
1574 }
1575 }
1576
1577 fn force_push_with_git_cli(&self, branch: &str) -> Result<()> {
1580 tracing::info!(
1581 "Using git CLI fallback for force push operation: {}",
1582 branch
1583 );
1584
1585 let output = std::process::Command::new("git")
1586 .args(["push", "--force", "origin", branch])
1587 .current_dir(&self.path)
1588 .output()
1589 .map_err(|e| CascadeError::branch(format!("Failed to execute git command: {e}")))?;
1590
1591 if output.status.success() {
1592 tracing::info!("✅ Git CLI force push succeeded for branch: {}", branch);
1593 Ok(())
1594 } else {
1595 let stderr = String::from_utf8_lossy(&output.stderr);
1596 let stdout = String::from_utf8_lossy(&output.stdout);
1597 let error_msg = format!(
1598 "Git CLI force push failed for branch '{}': {}\nStdout: {}\nStderr: {}",
1599 branch, output.status, stdout, stderr
1600 );
1601 tracing::error!("{}", error_msg);
1602 Err(CascadeError::branch(error_msg))
1603 }
1604 }
1605
1606 pub fn delete_branch(&self, name: &str) -> Result<()> {
1608 self.delete_branch_with_options(name, false)
1609 }
1610
1611 pub fn delete_branch_unsafe(&self, name: &str) -> Result<()> {
1613 self.delete_branch_with_options(name, true)
1614 }
1615
1616 fn delete_branch_with_options(&self, name: &str, force_unsafe: bool) -> Result<()> {
1618 info!("Attempting to delete branch: {}", name);
1619
1620 if !force_unsafe {
1622 let safety_result = self.check_branch_deletion_safety(name)?;
1623 if let Some(safety_info) = safety_result {
1624 self.handle_branch_deletion_confirmation(name, &safety_info)?;
1626 }
1627 }
1628
1629 let mut branch = self
1630 .repo
1631 .find_branch(name, git2::BranchType::Local)
1632 .map_err(|e| CascadeError::branch(format!("Could not find branch '{name}': {e}")))?;
1633
1634 branch
1635 .delete()
1636 .map_err(|e| CascadeError::branch(format!("Could not delete branch '{name}': {e}")))?;
1637
1638 info!("Successfully deleted branch '{}'", name);
1639 Ok(())
1640 }
1641
1642 pub fn get_commits_between(&self, from: &str, to: &str) -> Result<Vec<git2::Commit<'_>>> {
1644 let from_oid = self
1645 .repo
1646 .refname_to_id(&format!("refs/heads/{from}"))
1647 .or_else(|_| Oid::from_str(from))
1648 .map_err(|e| CascadeError::branch(format!("Invalid from reference '{from}': {e}")))?;
1649
1650 let to_oid = self
1651 .repo
1652 .refname_to_id(&format!("refs/heads/{to}"))
1653 .or_else(|_| Oid::from_str(to))
1654 .map_err(|e| CascadeError::branch(format!("Invalid to reference '{to}': {e}")))?;
1655
1656 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
1657
1658 revwalk.push(to_oid).map_err(CascadeError::Git)?;
1659 revwalk.hide(from_oid).map_err(CascadeError::Git)?;
1660
1661 let mut commits = Vec::new();
1662 for oid in revwalk {
1663 let oid = oid.map_err(CascadeError::Git)?;
1664 let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
1665 commits.push(commit);
1666 }
1667
1668 Ok(commits)
1669 }
1670
1671 pub fn force_push_branch(&self, target_branch: &str, source_branch: &str) -> Result<()> {
1674 self.force_push_branch_with_options(target_branch, source_branch, false)
1675 }
1676
1677 pub fn force_push_branch_unsafe(&self, target_branch: &str, source_branch: &str) -> Result<()> {
1679 self.force_push_branch_with_options(target_branch, source_branch, true)
1680 }
1681
1682 fn force_push_branch_with_options(
1684 &self,
1685 target_branch: &str,
1686 source_branch: &str,
1687 force_unsafe: bool,
1688 ) -> Result<()> {
1689 info!(
1690 "Force pushing {} content to {} to preserve PR history",
1691 source_branch, target_branch
1692 );
1693
1694 if !force_unsafe {
1696 let safety_result = self.check_force_push_safety_enhanced(target_branch)?;
1697 if let Some(backup_info) = safety_result {
1698 self.create_backup_branch(target_branch, &backup_info.remote_commit_id)?;
1700 info!(
1701 "✅ Created backup branch: {}",
1702 backup_info.backup_branch_name
1703 );
1704 }
1705 }
1706
1707 let source_ref = self
1709 .repo
1710 .find_reference(&format!("refs/heads/{source_branch}"))
1711 .map_err(|e| {
1712 CascadeError::config(format!("Failed to find source branch {source_branch}: {e}"))
1713 })?;
1714 let _source_commit = source_ref.peel_to_commit().map_err(|e| {
1715 CascadeError::config(format!(
1716 "Failed to get commit for source branch {source_branch}: {e}"
1717 ))
1718 })?;
1719
1720 let mut remote = self
1722 .repo
1723 .find_remote("origin")
1724 .map_err(|e| CascadeError::config(format!("Failed to find origin remote: {e}")))?;
1725
1726 let refspec = format!("+refs/heads/{source_branch}:refs/heads/{target_branch}");
1728
1729 let callbacks = self.configure_remote_callbacks()?;
1731
1732 let mut push_options = git2::PushOptions::new();
1734 push_options.remote_callbacks(callbacks);
1735
1736 match remote.push(&[&refspec], Some(&mut push_options)) {
1737 Ok(_) => {}
1738 Err(e) => {
1739 if self.should_retry_with_default_credentials(&e) {
1740 tracing::debug!(
1741 "Authentication error detected (class: {:?}, code: {:?}): {}, retrying with DefaultCredentials",
1742 e.class(), e.code(), e
1743 );
1744
1745 let callbacks = self.configure_remote_callbacks_with_fallback(true)?;
1747 let mut push_options = git2::PushOptions::new();
1748 push_options.remote_callbacks(callbacks);
1749
1750 match remote.push(&[&refspec], Some(&mut push_options)) {
1751 Ok(_) => {
1752 tracing::debug!("Force push succeeded with DefaultCredentials");
1753 }
1755 Err(retry_error) => {
1756 tracing::debug!(
1757 "DefaultCredentials retry failed: {}, falling back to git CLI",
1758 retry_error
1759 );
1760 return self.force_push_with_git_cli(target_branch);
1761 }
1762 }
1763 } else if self.should_fallback_to_git_cli(&e) {
1764 tracing::debug!(
1765 "Network/SSL error detected (class: {:?}, code: {:?}): {}, falling back to git CLI for force push operation",
1766 e.class(), e.code(), e
1767 );
1768 return self.force_push_with_git_cli(target_branch);
1769 } else {
1770 return Err(CascadeError::config(format!(
1771 "Failed to force push {target_branch}: {e}"
1772 )));
1773 }
1774 }
1775 }
1776
1777 info!(
1778 "✅ Successfully force pushed {} to preserve PR history",
1779 target_branch
1780 );
1781 Ok(())
1782 }
1783
1784 fn check_force_push_safety_enhanced(
1787 &self,
1788 target_branch: &str,
1789 ) -> Result<Option<ForceBackupInfo>> {
1790 match self.fetch() {
1792 Ok(_) => {}
1793 Err(e) => {
1794 warn!("Could not fetch latest changes for safety check: {}", e);
1796 }
1797 }
1798
1799 let remote_ref = format!("refs/remotes/origin/{target_branch}");
1801 let local_ref = format!("refs/heads/{target_branch}");
1802
1803 let local_commit = match self.repo.find_reference(&local_ref) {
1805 Ok(reference) => reference.peel_to_commit().ok(),
1806 Err(_) => None,
1807 };
1808
1809 let remote_commit = match self.repo.find_reference(&remote_ref) {
1810 Ok(reference) => reference.peel_to_commit().ok(),
1811 Err(_) => None,
1812 };
1813
1814 if let (Some(local), Some(remote)) = (local_commit, remote_commit) {
1816 if local.id() != remote.id() {
1817 let merge_base_oid = self
1819 .repo
1820 .merge_base(local.id(), remote.id())
1821 .map_err(|e| CascadeError::config(format!("Failed to find merge base: {e}")))?;
1822
1823 if merge_base_oid != remote.id() {
1825 let commits_to_lose = self.count_commits_between(
1826 &merge_base_oid.to_string(),
1827 &remote.id().to_string(),
1828 )?;
1829
1830 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1832 let backup_branch_name = format!("{target_branch}_backup_{timestamp}");
1833
1834 warn!(
1835 "⚠️ Force push to '{}' would overwrite {} commits on remote",
1836 target_branch, commits_to_lose
1837 );
1838
1839 if std::env::var("CI").is_ok() || std::env::var("FORCE_PUSH_NO_CONFIRM").is_ok()
1841 {
1842 info!(
1843 "Non-interactive environment detected, proceeding with backup creation"
1844 );
1845 return Ok(Some(ForceBackupInfo {
1846 backup_branch_name,
1847 remote_commit_id: remote.id().to_string(),
1848 commits_that_would_be_lost: commits_to_lose,
1849 }));
1850 }
1851
1852 println!("\n⚠️ FORCE PUSH WARNING ⚠️");
1854 println!("Force push to '{target_branch}' would overwrite {commits_to_lose} commits on remote:");
1855
1856 match self
1858 .get_commits_between(&merge_base_oid.to_string(), &remote.id().to_string())
1859 {
1860 Ok(commits) => {
1861 println!("\nCommits that would be lost:");
1862 for (i, commit) in commits.iter().take(5).enumerate() {
1863 let short_hash = &commit.id().to_string()[..8];
1864 let summary = commit.summary().unwrap_or("<no message>");
1865 println!(" {}. {} - {}", i + 1, short_hash, summary);
1866 }
1867 if commits.len() > 5 {
1868 println!(" ... and {} more commits", commits.len() - 5);
1869 }
1870 }
1871 Err(_) => {
1872 println!(" (Unable to retrieve commit details)");
1873 }
1874 }
1875
1876 println!("\nA backup branch '{backup_branch_name}' will be created before proceeding.");
1877
1878 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
1879 .with_prompt("Do you want to proceed with the force push?")
1880 .default(false)
1881 .interact()
1882 .map_err(|e| {
1883 CascadeError::config(format!("Failed to get user confirmation: {e}"))
1884 })?;
1885
1886 if !confirmed {
1887 return Err(CascadeError::config(
1888 "Force push cancelled by user. Use --force to bypass this check."
1889 .to_string(),
1890 ));
1891 }
1892
1893 return Ok(Some(ForceBackupInfo {
1894 backup_branch_name,
1895 remote_commit_id: remote.id().to_string(),
1896 commits_that_would_be_lost: commits_to_lose,
1897 }));
1898 }
1899 }
1900 }
1901
1902 Ok(None)
1903 }
1904
1905 fn create_backup_branch(&self, original_branch: &str, remote_commit_id: &str) -> Result<()> {
1907 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1908 let backup_branch_name = format!("{original_branch}_backup_{timestamp}");
1909
1910 let commit_oid = Oid::from_str(remote_commit_id).map_err(|e| {
1912 CascadeError::config(format!("Invalid commit ID {remote_commit_id}: {e}"))
1913 })?;
1914
1915 let commit = self.repo.find_commit(commit_oid).map_err(|e| {
1917 CascadeError::config(format!("Failed to find commit {remote_commit_id}: {e}"))
1918 })?;
1919
1920 self.repo
1922 .branch(&backup_branch_name, &commit, false)
1923 .map_err(|e| {
1924 CascadeError::config(format!(
1925 "Failed to create backup branch {backup_branch_name}: {e}"
1926 ))
1927 })?;
1928
1929 info!(
1930 "✅ Created backup branch '{}' pointing to {}",
1931 backup_branch_name,
1932 &remote_commit_id[..8]
1933 );
1934 Ok(())
1935 }
1936
1937 fn check_branch_deletion_safety(
1940 &self,
1941 branch_name: &str,
1942 ) -> Result<Option<BranchDeletionSafety>> {
1943 match self.fetch() {
1945 Ok(_) => {}
1946 Err(e) => {
1947 warn!(
1948 "Could not fetch latest changes for branch deletion safety check: {}",
1949 e
1950 );
1951 }
1952 }
1953
1954 let branch = self
1956 .repo
1957 .find_branch(branch_name, git2::BranchType::Local)
1958 .map_err(|e| {
1959 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
1960 })?;
1961
1962 let _branch_commit = branch.get().peel_to_commit().map_err(|e| {
1963 CascadeError::branch(format!(
1964 "Could not get commit for branch '{branch_name}': {e}"
1965 ))
1966 })?;
1967
1968 let main_branch_name = self.detect_main_branch()?;
1970
1971 let is_merged_to_main = self.is_branch_merged_to_main(branch_name, &main_branch_name)?;
1973
1974 let remote_tracking_branch = self.get_remote_tracking_branch(branch_name);
1976
1977 let mut unpushed_commits = Vec::new();
1978
1979 if let Some(ref remote_branch) = remote_tracking_branch {
1981 match self.get_commits_between(remote_branch, branch_name) {
1982 Ok(commits) => {
1983 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1984 }
1985 Err(_) => {
1986 if !is_merged_to_main {
1988 if let Ok(commits) =
1989 self.get_commits_between(&main_branch_name, branch_name)
1990 {
1991 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1992 }
1993 }
1994 }
1995 }
1996 } else if !is_merged_to_main {
1997 if let Ok(commits) = self.get_commits_between(&main_branch_name, branch_name) {
1999 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
2000 }
2001 }
2002
2003 if !unpushed_commits.is_empty() || (!is_merged_to_main && remote_tracking_branch.is_none())
2005 {
2006 Ok(Some(BranchDeletionSafety {
2007 unpushed_commits,
2008 remote_tracking_branch,
2009 is_merged_to_main,
2010 main_branch_name,
2011 }))
2012 } else {
2013 Ok(None)
2014 }
2015 }
2016
2017 fn handle_branch_deletion_confirmation(
2019 &self,
2020 branch_name: &str,
2021 safety_info: &BranchDeletionSafety,
2022 ) -> Result<()> {
2023 if std::env::var("CI").is_ok() || std::env::var("BRANCH_DELETE_NO_CONFIRM").is_ok() {
2025 return Err(CascadeError::branch(
2026 format!(
2027 "Branch '{branch_name}' has {} unpushed commits and cannot be deleted in non-interactive mode. Use --force to override.",
2028 safety_info.unpushed_commits.len()
2029 )
2030 ));
2031 }
2032
2033 println!("\n⚠️ BRANCH DELETION WARNING ⚠️");
2035 println!("Branch '{branch_name}' has potential issues:");
2036
2037 if !safety_info.unpushed_commits.is_empty() {
2038 println!(
2039 "\n🔍 Unpushed commits ({} total):",
2040 safety_info.unpushed_commits.len()
2041 );
2042
2043 for (i, commit_id) in safety_info.unpushed_commits.iter().take(5).enumerate() {
2045 if let Ok(commit) = self.repo.find_commit(Oid::from_str(commit_id).unwrap()) {
2046 let short_hash = &commit_id[..8];
2047 let summary = commit.summary().unwrap_or("<no message>");
2048 println!(" {}. {} - {}", i + 1, short_hash, summary);
2049 }
2050 }
2051
2052 if safety_info.unpushed_commits.len() > 5 {
2053 println!(
2054 " ... and {} more commits",
2055 safety_info.unpushed_commits.len() - 5
2056 );
2057 }
2058 }
2059
2060 if !safety_info.is_merged_to_main {
2061 println!("\n📋 Branch status:");
2062 println!(" • Not merged to '{}'", safety_info.main_branch_name);
2063 if let Some(ref remote) = safety_info.remote_tracking_branch {
2064 println!(" • Remote tracking branch: {remote}");
2065 } else {
2066 println!(" • No remote tracking branch");
2067 }
2068 }
2069
2070 println!("\n💡 Safer alternatives:");
2071 if !safety_info.unpushed_commits.is_empty() {
2072 if let Some(ref _remote) = safety_info.remote_tracking_branch {
2073 println!(" • Push commits first: git push origin {branch_name}");
2074 } else {
2075 println!(" • Create and push to remote: git push -u origin {branch_name}");
2076 }
2077 }
2078 if !safety_info.is_merged_to_main {
2079 println!(
2080 " • Merge to {} first: git checkout {} && git merge {branch_name}",
2081 safety_info.main_branch_name, safety_info.main_branch_name
2082 );
2083 }
2084
2085 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
2086 .with_prompt("Do you want to proceed with deleting this branch?")
2087 .default(false)
2088 .interact()
2089 .map_err(|e| CascadeError::branch(format!("Failed to get user confirmation: {e}")))?;
2090
2091 if !confirmed {
2092 return Err(CascadeError::branch(
2093 "Branch deletion cancelled by user. Use --force to bypass this check.".to_string(),
2094 ));
2095 }
2096
2097 Ok(())
2098 }
2099
2100 pub fn detect_main_branch(&self) -> Result<String> {
2102 let main_candidates = ["main", "master", "develop", "trunk"];
2103
2104 for candidate in &main_candidates {
2105 if self
2106 .repo
2107 .find_branch(candidate, git2::BranchType::Local)
2108 .is_ok()
2109 {
2110 return Ok(candidate.to_string());
2111 }
2112 }
2113
2114 if let Ok(head) = self.repo.head() {
2116 if let Some(name) = head.shorthand() {
2117 return Ok(name.to_string());
2118 }
2119 }
2120
2121 Ok("main".to_string())
2123 }
2124
2125 fn is_branch_merged_to_main(&self, branch_name: &str, main_branch: &str) -> Result<bool> {
2127 match self.get_commits_between(main_branch, branch_name) {
2129 Ok(commits) => Ok(commits.is_empty()),
2130 Err(_) => {
2131 Ok(false)
2133 }
2134 }
2135 }
2136
2137 fn get_remote_tracking_branch(&self, branch_name: &str) -> Option<String> {
2139 let remote_candidates = [
2141 format!("origin/{branch_name}"),
2142 format!("remotes/origin/{branch_name}"),
2143 ];
2144
2145 for candidate in &remote_candidates {
2146 if self
2147 .repo
2148 .find_reference(&format!(
2149 "refs/remotes/{}",
2150 candidate.replace("remotes/", "")
2151 ))
2152 .is_ok()
2153 {
2154 return Some(candidate.clone());
2155 }
2156 }
2157
2158 None
2159 }
2160
2161 fn check_checkout_safety(&self, _target: &str) -> Result<Option<CheckoutSafety>> {
2163 let is_dirty = self.is_dirty()?;
2165 if !is_dirty {
2166 return Ok(None);
2168 }
2169
2170 let current_branch = self.get_current_branch().ok();
2172
2173 let modified_files = self.get_modified_files()?;
2175 let staged_files = self.get_staged_files()?;
2176 let untracked_files = self.get_untracked_files()?;
2177
2178 let has_uncommitted_changes = !modified_files.is_empty() || !staged_files.is_empty();
2179
2180 if has_uncommitted_changes || !untracked_files.is_empty() {
2181 return Ok(Some(CheckoutSafety {
2182 has_uncommitted_changes,
2183 modified_files,
2184 staged_files,
2185 untracked_files,
2186 stash_created: None,
2187 current_branch,
2188 }));
2189 }
2190
2191 Ok(None)
2192 }
2193
2194 fn handle_checkout_confirmation(
2196 &self,
2197 target: &str,
2198 safety_info: &CheckoutSafety,
2199 ) -> Result<()> {
2200 let is_ci = std::env::var("CI").is_ok();
2202 let no_confirm = std::env::var("CHECKOUT_NO_CONFIRM").is_ok();
2203 let is_non_interactive = is_ci || no_confirm;
2204
2205 if is_non_interactive {
2206 return Err(CascadeError::branch(
2207 format!(
2208 "Cannot checkout '{target}' with uncommitted changes in non-interactive mode. Commit your changes or use stash first."
2209 )
2210 ));
2211 }
2212
2213 println!("\n⚠️ CHECKOUT WARNING ⚠️");
2215 println!("You have uncommitted changes that could be lost:");
2216
2217 if !safety_info.modified_files.is_empty() {
2218 println!(
2219 "\n📝 Modified files ({}):",
2220 safety_info.modified_files.len()
2221 );
2222 for file in safety_info.modified_files.iter().take(10) {
2223 println!(" - {file}");
2224 }
2225 if safety_info.modified_files.len() > 10 {
2226 println!(" ... and {} more", safety_info.modified_files.len() - 10);
2227 }
2228 }
2229
2230 if !safety_info.staged_files.is_empty() {
2231 println!("\n📁 Staged files ({}):", safety_info.staged_files.len());
2232 for file in safety_info.staged_files.iter().take(10) {
2233 println!(" - {file}");
2234 }
2235 if safety_info.staged_files.len() > 10 {
2236 println!(" ... and {} more", safety_info.staged_files.len() - 10);
2237 }
2238 }
2239
2240 if !safety_info.untracked_files.is_empty() {
2241 println!(
2242 "\n❓ Untracked files ({}):",
2243 safety_info.untracked_files.len()
2244 );
2245 for file in safety_info.untracked_files.iter().take(5) {
2246 println!(" - {file}");
2247 }
2248 if safety_info.untracked_files.len() > 5 {
2249 println!(" ... and {} more", safety_info.untracked_files.len() - 5);
2250 }
2251 }
2252
2253 println!("\n🔄 Options:");
2254 println!("1. Stash changes and checkout (recommended)");
2255 println!("2. Force checkout (WILL LOSE UNCOMMITTED CHANGES)");
2256 println!("3. Cancel checkout");
2257
2258 let selection = Select::with_theme(&ColorfulTheme::default())
2260 .with_prompt("Choose an action")
2261 .items(&[
2262 "Stash changes and checkout (recommended)",
2263 "Force checkout (WILL LOSE UNCOMMITTED CHANGES)",
2264 "Cancel checkout",
2265 ])
2266 .default(0)
2267 .interact()
2268 .map_err(|e| CascadeError::branch(format!("Could not get user selection: {e}")))?;
2269
2270 match selection {
2271 0 => {
2272 let stash_message = format!(
2274 "Auto-stash before checkout to {} at {}",
2275 target,
2276 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
2277 );
2278
2279 match self.create_stash(&stash_message) {
2280 Ok(stash_id) => {
2281 println!("✅ Created stash: {stash_message} ({stash_id})");
2282 println!("💡 You can restore with: git stash pop");
2283 }
2284 Err(e) => {
2285 println!("❌ Failed to create stash: {e}");
2286
2287 use dialoguer::Select;
2289 let stash_failed_options = vec![
2290 "Commit staged changes and proceed",
2291 "Force checkout (WILL LOSE CHANGES)",
2292 "Cancel and handle manually",
2293 ];
2294
2295 let stash_selection = Select::with_theme(&ColorfulTheme::default())
2296 .with_prompt("Stash failed. What would you like to do?")
2297 .items(&stash_failed_options)
2298 .default(0)
2299 .interact()
2300 .map_err(|e| {
2301 CascadeError::branch(format!("Could not get user selection: {e}"))
2302 })?;
2303
2304 match stash_selection {
2305 0 => {
2306 let staged_files = self.get_staged_files()?;
2308 if !staged_files.is_empty() {
2309 println!(
2310 "📝 Committing {} staged files...",
2311 staged_files.len()
2312 );
2313 match self
2314 .commit_staged_changes("WIP: Auto-commit before checkout")
2315 {
2316 Ok(Some(commit_hash)) => {
2317 println!(
2318 "✅ Committed staged changes as {}",
2319 &commit_hash[..8]
2320 );
2321 println!("💡 You can undo with: git reset HEAD~1");
2322 }
2323 Ok(None) => {
2324 println!("ℹ️ No staged changes found to commit");
2325 }
2326 Err(commit_err) => {
2327 println!(
2328 "❌ Failed to commit staged changes: {commit_err}"
2329 );
2330 return Err(CascadeError::branch(
2331 "Could not commit staged changes".to_string(),
2332 ));
2333 }
2334 }
2335 } else {
2336 println!("ℹ️ No staged changes to commit");
2337 }
2338 }
2339 1 => {
2340 println!("⚠️ Proceeding with force checkout - uncommitted changes will be lost!");
2342 }
2343 2 => {
2344 return Err(CascadeError::branch(
2346 "Checkout cancelled. Please handle changes manually and try again.".to_string(),
2347 ));
2348 }
2349 _ => unreachable!(),
2350 }
2351 }
2352 }
2353 }
2354 1 => {
2355 println!("⚠️ Proceeding with force checkout - uncommitted changes will be lost!");
2357 }
2358 2 => {
2359 return Err(CascadeError::branch(
2361 "Checkout cancelled by user".to_string(),
2362 ));
2363 }
2364 _ => unreachable!(),
2365 }
2366
2367 Ok(())
2368 }
2369
2370 fn create_stash(&self, message: &str) -> Result<String> {
2372 tracing::info!("Creating stash: {}", message);
2373
2374 let output = std::process::Command::new("git")
2376 .args(["stash", "push", "-m", message])
2377 .current_dir(&self.path)
2378 .output()
2379 .map_err(|e| {
2380 CascadeError::branch(format!("Failed to execute git stash command: {e}"))
2381 })?;
2382
2383 if output.status.success() {
2384 let stdout = String::from_utf8_lossy(&output.stdout);
2385
2386 let stash_id = if stdout.contains("Saved working directory") {
2388 let stash_list_output = std::process::Command::new("git")
2390 .args(["stash", "list", "-n", "1", "--format=%H"])
2391 .current_dir(&self.path)
2392 .output()
2393 .map_err(|e| CascadeError::branch(format!("Failed to get stash ID: {e}")))?;
2394
2395 if stash_list_output.status.success() {
2396 String::from_utf8_lossy(&stash_list_output.stdout)
2397 .trim()
2398 .to_string()
2399 } else {
2400 "stash@{0}".to_string() }
2402 } else {
2403 "stash@{0}".to_string() };
2405
2406 tracing::info!("✅ Created stash: {} ({})", message, stash_id);
2407 Ok(stash_id)
2408 } else {
2409 let stderr = String::from_utf8_lossy(&output.stderr);
2410 let stdout = String::from_utf8_lossy(&output.stdout);
2411
2412 if stderr.contains("No local changes to save")
2414 || stdout.contains("No local changes to save")
2415 {
2416 return Err(CascadeError::branch("No local changes to save".to_string()));
2417 }
2418
2419 Err(CascadeError::branch(format!(
2420 "Failed to create stash: {}\nStderr: {}\nStdout: {}",
2421 output.status, stderr, stdout
2422 )))
2423 }
2424 }
2425
2426 fn get_modified_files(&self) -> Result<Vec<String>> {
2428 let mut opts = git2::StatusOptions::new();
2429 opts.include_untracked(false).include_ignored(false);
2430
2431 let statuses = self
2432 .repo
2433 .statuses(Some(&mut opts))
2434 .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
2435
2436 let mut modified_files = Vec::new();
2437 for status in statuses.iter() {
2438 let flags = status.status();
2439 if flags.contains(git2::Status::WT_MODIFIED) || flags.contains(git2::Status::WT_DELETED)
2440 {
2441 if let Some(path) = status.path() {
2442 modified_files.push(path.to_string());
2443 }
2444 }
2445 }
2446
2447 Ok(modified_files)
2448 }
2449
2450 pub fn get_staged_files(&self) -> Result<Vec<String>> {
2452 let mut opts = git2::StatusOptions::new();
2453 opts.include_untracked(false).include_ignored(false);
2454
2455 let statuses = self
2456 .repo
2457 .statuses(Some(&mut opts))
2458 .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
2459
2460 let mut staged_files = Vec::new();
2461 for status in statuses.iter() {
2462 let flags = status.status();
2463 if flags.contains(git2::Status::INDEX_MODIFIED)
2464 || flags.contains(git2::Status::INDEX_NEW)
2465 || flags.contains(git2::Status::INDEX_DELETED)
2466 {
2467 if let Some(path) = status.path() {
2468 staged_files.push(path.to_string());
2469 }
2470 }
2471 }
2472
2473 Ok(staged_files)
2474 }
2475
2476 fn count_commits_between(&self, from: &str, to: &str) -> Result<usize> {
2478 let commits = self.get_commits_between(from, to)?;
2479 Ok(commits.len())
2480 }
2481
2482 pub fn resolve_reference(&self, reference: &str) -> Result<git2::Commit<'_>> {
2484 if let Ok(oid) = Oid::from_str(reference) {
2486 if let Ok(commit) = self.repo.find_commit(oid) {
2487 return Ok(commit);
2488 }
2489 }
2490
2491 let obj = self.repo.revparse_single(reference).map_err(|e| {
2493 CascadeError::branch(format!("Could not resolve reference '{reference}': {e}"))
2494 })?;
2495
2496 obj.peel_to_commit().map_err(|e| {
2497 CascadeError::branch(format!(
2498 "Reference '{reference}' does not point to a commit: {e}"
2499 ))
2500 })
2501 }
2502
2503 pub fn reset_soft(&self, target_ref: &str) -> Result<()> {
2505 let target_commit = self.resolve_reference(target_ref)?;
2506
2507 self.repo
2508 .reset(target_commit.as_object(), git2::ResetType::Soft, None)
2509 .map_err(CascadeError::Git)?;
2510
2511 Ok(())
2512 }
2513
2514 pub fn reset_to_head(&self) -> Result<()> {
2517 tracing::debug!("Resetting working directory and index to HEAD");
2518
2519 let head = self.repo.head().map_err(CascadeError::Git)?;
2520 let head_commit = head.peel_to_commit().map_err(CascadeError::Git)?;
2521
2522 let mut checkout_builder = git2::build::CheckoutBuilder::new();
2524 checkout_builder.force(); checkout_builder.remove_untracked(false); self.repo
2528 .reset(
2529 head_commit.as_object(),
2530 git2::ResetType::Hard,
2531 Some(&mut checkout_builder),
2532 )
2533 .map_err(CascadeError::Git)?;
2534
2535 tracing::debug!("Successfully reset working directory to HEAD");
2536 Ok(())
2537 }
2538
2539 pub fn find_branch_containing_commit(&self, commit_hash: &str) -> Result<String> {
2541 let oid = Oid::from_str(commit_hash).map_err(|e| {
2542 CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
2543 })?;
2544
2545 let branches = self
2547 .repo
2548 .branches(Some(git2::BranchType::Local))
2549 .map_err(CascadeError::Git)?;
2550
2551 for branch_result in branches {
2552 let (branch, _) = branch_result.map_err(CascadeError::Git)?;
2553
2554 if let Some(branch_name) = branch.name().map_err(CascadeError::Git)? {
2555 if let Ok(branch_head) = branch.get().peel_to_commit() {
2557 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
2559 revwalk.push(branch_head.id()).map_err(CascadeError::Git)?;
2560
2561 for commit_oid in revwalk {
2562 let commit_oid = commit_oid.map_err(CascadeError::Git)?;
2563 if commit_oid == oid {
2564 return Ok(branch_name.to_string());
2565 }
2566 }
2567 }
2568 }
2569 }
2570
2571 Err(CascadeError::branch(format!(
2573 "Commit {commit_hash} not found in any local branch"
2574 )))
2575 }
2576
2577 pub async fn fetch_async(&self) -> Result<()> {
2581 let repo_path = self.path.clone();
2582 crate::utils::async_ops::run_git_operation(move || {
2583 let repo = GitRepository::open(&repo_path)?;
2584 repo.fetch()
2585 })
2586 .await
2587 }
2588
2589 pub async fn pull_async(&self, branch: &str) -> Result<()> {
2591 let repo_path = self.path.clone();
2592 let branch_name = branch.to_string();
2593 crate::utils::async_ops::run_git_operation(move || {
2594 let repo = GitRepository::open(&repo_path)?;
2595 repo.pull(&branch_name)
2596 })
2597 .await
2598 }
2599
2600 pub async fn push_branch_async(&self, branch_name: &str) -> Result<()> {
2602 let repo_path = self.path.clone();
2603 let branch = branch_name.to_string();
2604 crate::utils::async_ops::run_git_operation(move || {
2605 let repo = GitRepository::open(&repo_path)?;
2606 repo.push(&branch)
2607 })
2608 .await
2609 }
2610
2611 pub async fn cherry_pick_commit_async(&self, commit_hash: &str) -> Result<String> {
2613 let repo_path = self.path.clone();
2614 let hash = commit_hash.to_string();
2615 crate::utils::async_ops::run_git_operation(move || {
2616 let repo = GitRepository::open(&repo_path)?;
2617 repo.cherry_pick(&hash)
2618 })
2619 .await
2620 }
2621
2622 pub async fn get_commit_hashes_between_async(
2624 &self,
2625 from: &str,
2626 to: &str,
2627 ) -> Result<Vec<String>> {
2628 let repo_path = self.path.clone();
2629 let from_str = from.to_string();
2630 let to_str = to.to_string();
2631 crate::utils::async_ops::run_git_operation(move || {
2632 let repo = GitRepository::open(&repo_path)?;
2633 let commits = repo.get_commits_between(&from_str, &to_str)?;
2634 Ok(commits.into_iter().map(|c| c.id().to_string()).collect())
2635 })
2636 .await
2637 }
2638
2639 pub fn reset_branch_to_commit(&self, branch_name: &str, commit_hash: &str) -> Result<()> {
2641 info!(
2642 "Resetting branch '{}' to commit {}",
2643 branch_name,
2644 &commit_hash[..8]
2645 );
2646
2647 let target_oid = git2::Oid::from_str(commit_hash).map_err(|e| {
2649 CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
2650 })?;
2651
2652 let _target_commit = self.repo.find_commit(target_oid).map_err(|e| {
2653 CascadeError::branch(format!("Could not find commit '{commit_hash}': {e}"))
2654 })?;
2655
2656 let _branch = self
2658 .repo
2659 .find_branch(branch_name, git2::BranchType::Local)
2660 .map_err(|e| {
2661 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
2662 })?;
2663
2664 let branch_ref_name = format!("refs/heads/{branch_name}");
2666 self.repo
2667 .reference(
2668 &branch_ref_name,
2669 target_oid,
2670 true,
2671 &format!("Reset {branch_name} to {commit_hash}"),
2672 )
2673 .map_err(|e| {
2674 CascadeError::branch(format!(
2675 "Could not reset branch '{branch_name}' to commit '{commit_hash}': {e}"
2676 ))
2677 })?;
2678
2679 tracing::info!(
2680 "Successfully reset branch '{}' to commit {}",
2681 branch_name,
2682 &commit_hash[..8]
2683 );
2684 Ok(())
2685 }
2686
2687 pub fn detect_parent_branch(&self) -> Result<Option<String>> {
2689 let current_branch = self.get_current_branch()?;
2690
2691 if let Ok(Some(upstream)) = self.get_upstream_branch(¤t_branch) {
2693 if let Some(branch_name) = upstream.split('/').nth(1) {
2695 if self.branch_exists(branch_name) {
2696 tracing::debug!(
2697 "Detected parent branch '{}' from upstream tracking",
2698 branch_name
2699 );
2700 return Ok(Some(branch_name.to_string()));
2701 }
2702 }
2703 }
2704
2705 if let Ok(default_branch) = self.detect_main_branch() {
2707 if current_branch != default_branch {
2709 tracing::debug!(
2710 "Detected parent branch '{}' as repository default",
2711 default_branch
2712 );
2713 return Ok(Some(default_branch));
2714 }
2715 }
2716
2717 if let Ok(branches) = self.list_branches() {
2720 let current_commit = self.get_head_commit()?;
2721 let current_commit_hash = current_commit.id().to_string();
2722 let current_oid = current_commit.id();
2723
2724 let mut best_candidate = None;
2725 let mut best_distance = usize::MAX;
2726
2727 for branch in branches {
2728 if branch == current_branch
2730 || branch.contains("-v")
2731 || branch.ends_with("-v2")
2732 || branch.ends_with("-v3")
2733 {
2734 continue;
2735 }
2736
2737 if let Ok(base_commit_hash) = self.get_branch_commit_hash(&branch) {
2738 if let Ok(base_oid) = git2::Oid::from_str(&base_commit_hash) {
2739 if let Ok(merge_base_oid) = self.repo.merge_base(current_oid, base_oid) {
2741 if let Ok(distance) = self.count_commits_between(
2743 &merge_base_oid.to_string(),
2744 ¤t_commit_hash,
2745 ) {
2746 let is_likely_base = self.is_likely_base_branch(&branch);
2749 let adjusted_distance = if is_likely_base {
2750 distance
2751 } else {
2752 distance + 1000
2753 };
2754
2755 if adjusted_distance < best_distance {
2756 best_distance = adjusted_distance;
2757 best_candidate = Some(branch.clone());
2758 }
2759 }
2760 }
2761 }
2762 }
2763 }
2764
2765 if let Some(ref candidate) = best_candidate {
2766 tracing::debug!(
2767 "Detected parent branch '{}' with distance {}",
2768 candidate,
2769 best_distance
2770 );
2771 }
2772
2773 return Ok(best_candidate);
2774 }
2775
2776 tracing::debug!("Could not detect parent branch for '{}'", current_branch);
2777 Ok(None)
2778 }
2779
2780 fn is_likely_base_branch(&self, branch_name: &str) -> bool {
2782 let base_patterns = [
2783 "main",
2784 "master",
2785 "develop",
2786 "dev",
2787 "development",
2788 "staging",
2789 "stage",
2790 "release",
2791 "production",
2792 "prod",
2793 ];
2794
2795 base_patterns.contains(&branch_name)
2796 }
2797}
2798
2799#[cfg(test)]
2800mod tests {
2801 use super::*;
2802 use std::process::Command;
2803 use tempfile::TempDir;
2804
2805 fn create_test_repo() -> (TempDir, PathBuf) {
2806 let temp_dir = TempDir::new().unwrap();
2807 let repo_path = temp_dir.path().to_path_buf();
2808
2809 Command::new("git")
2811 .args(["init"])
2812 .current_dir(&repo_path)
2813 .output()
2814 .unwrap();
2815 Command::new("git")
2816 .args(["config", "user.name", "Test"])
2817 .current_dir(&repo_path)
2818 .output()
2819 .unwrap();
2820 Command::new("git")
2821 .args(["config", "user.email", "test@test.com"])
2822 .current_dir(&repo_path)
2823 .output()
2824 .unwrap();
2825
2826 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
2828 Command::new("git")
2829 .args(["add", "."])
2830 .current_dir(&repo_path)
2831 .output()
2832 .unwrap();
2833 Command::new("git")
2834 .args(["commit", "-m", "Initial commit"])
2835 .current_dir(&repo_path)
2836 .output()
2837 .unwrap();
2838
2839 (temp_dir, repo_path)
2840 }
2841
2842 fn create_commit(repo_path: &PathBuf, message: &str, filename: &str) {
2843 let file_path = repo_path.join(filename);
2844 std::fs::write(&file_path, format!("Content for {filename}\n")).unwrap();
2845
2846 Command::new("git")
2847 .args(["add", filename])
2848 .current_dir(repo_path)
2849 .output()
2850 .unwrap();
2851 Command::new("git")
2852 .args(["commit", "-m", message])
2853 .current_dir(repo_path)
2854 .output()
2855 .unwrap();
2856 }
2857
2858 #[test]
2859 fn test_repository_info() {
2860 let (_temp_dir, repo_path) = create_test_repo();
2861 let repo = GitRepository::open(&repo_path).unwrap();
2862
2863 let info = repo.get_info().unwrap();
2864 assert!(!info.is_dirty); assert!(
2866 info.head_branch == Some("master".to_string())
2867 || info.head_branch == Some("main".to_string()),
2868 "Expected default branch to be 'master' or 'main', got {:?}",
2869 info.head_branch
2870 );
2871 assert!(info.head_commit.is_some()); assert!(info.untracked_files.is_empty()); }
2874
2875 #[test]
2876 fn test_force_push_branch_basic() {
2877 let (_temp_dir, repo_path) = create_test_repo();
2878 let repo = GitRepository::open(&repo_path).unwrap();
2879
2880 let default_branch = repo.get_current_branch().unwrap();
2882
2883 create_commit(&repo_path, "Feature commit 1", "feature1.rs");
2885 Command::new("git")
2886 .args(["checkout", "-b", "source-branch"])
2887 .current_dir(&repo_path)
2888 .output()
2889 .unwrap();
2890 create_commit(&repo_path, "Feature commit 2", "feature2.rs");
2891
2892 Command::new("git")
2894 .args(["checkout", &default_branch])
2895 .current_dir(&repo_path)
2896 .output()
2897 .unwrap();
2898 Command::new("git")
2899 .args(["checkout", "-b", "target-branch"])
2900 .current_dir(&repo_path)
2901 .output()
2902 .unwrap();
2903 create_commit(&repo_path, "Target commit", "target.rs");
2904
2905 let result = repo.force_push_branch("target-branch", "source-branch");
2907
2908 assert!(result.is_ok() || result.is_err()); }
2912
2913 #[test]
2914 fn test_force_push_branch_nonexistent_branches() {
2915 let (_temp_dir, repo_path) = create_test_repo();
2916 let repo = GitRepository::open(&repo_path).unwrap();
2917
2918 let default_branch = repo.get_current_branch().unwrap();
2920
2921 let result = repo.force_push_branch("target", "nonexistent-source");
2923 assert!(result.is_err());
2924
2925 let result = repo.force_push_branch("nonexistent-target", &default_branch);
2927 assert!(result.is_err());
2928 }
2929
2930 #[test]
2931 fn test_force_push_workflow_simulation() {
2932 let (_temp_dir, repo_path) = create_test_repo();
2933 let repo = GitRepository::open(&repo_path).unwrap();
2934
2935 Command::new("git")
2938 .args(["checkout", "-b", "feature-auth"])
2939 .current_dir(&repo_path)
2940 .output()
2941 .unwrap();
2942 create_commit(&repo_path, "Add authentication", "auth.rs");
2943
2944 Command::new("git")
2946 .args(["checkout", "-b", "feature-auth-v2"])
2947 .current_dir(&repo_path)
2948 .output()
2949 .unwrap();
2950 create_commit(&repo_path, "Fix auth validation", "auth.rs");
2951
2952 let result = repo.force_push_branch("feature-auth", "feature-auth-v2");
2954
2955 match result {
2957 Ok(_) => {
2958 Command::new("git")
2960 .args(["checkout", "feature-auth"])
2961 .current_dir(&repo_path)
2962 .output()
2963 .unwrap();
2964 let log_output = Command::new("git")
2965 .args(["log", "--oneline", "-2"])
2966 .current_dir(&repo_path)
2967 .output()
2968 .unwrap();
2969 let log_str = String::from_utf8_lossy(&log_output.stdout);
2970 assert!(
2971 log_str.contains("Fix auth validation")
2972 || log_str.contains("Add authentication")
2973 );
2974 }
2975 Err(_) => {
2976 }
2979 }
2980 }
2981
2982 #[test]
2983 fn test_branch_operations() {
2984 let (_temp_dir, repo_path) = create_test_repo();
2985 let repo = GitRepository::open(&repo_path).unwrap();
2986
2987 let current = repo.get_current_branch().unwrap();
2989 assert!(
2990 current == "master" || current == "main",
2991 "Expected default branch to be 'master' or 'main', got '{current}'"
2992 );
2993
2994 Command::new("git")
2996 .args(["checkout", "-b", "test-branch"])
2997 .current_dir(&repo_path)
2998 .output()
2999 .unwrap();
3000 let current = repo.get_current_branch().unwrap();
3001 assert_eq!(current, "test-branch");
3002 }
3003
3004 #[test]
3005 fn test_commit_operations() {
3006 let (_temp_dir, repo_path) = create_test_repo();
3007 let repo = GitRepository::open(&repo_path).unwrap();
3008
3009 let head = repo.get_head_commit().unwrap();
3011 assert_eq!(head.message().unwrap().trim(), "Initial commit");
3012
3013 let hash = head.id().to_string();
3015 let same_commit = repo.get_commit(&hash).unwrap();
3016 assert_eq!(head.id(), same_commit.id());
3017 }
3018
3019 #[test]
3020 fn test_checkout_safety_clean_repo() {
3021 let (_temp_dir, repo_path) = create_test_repo();
3022 let repo = GitRepository::open(&repo_path).unwrap();
3023
3024 create_commit(&repo_path, "Second commit", "test.txt");
3026 Command::new("git")
3027 .args(["checkout", "-b", "test-branch"])
3028 .current_dir(&repo_path)
3029 .output()
3030 .unwrap();
3031
3032 let safety_result = repo.check_checkout_safety("main");
3034 assert!(safety_result.is_ok());
3035 assert!(safety_result.unwrap().is_none()); }
3037
3038 #[test]
3039 fn test_checkout_safety_with_modified_files() {
3040 let (_temp_dir, repo_path) = create_test_repo();
3041 let repo = GitRepository::open(&repo_path).unwrap();
3042
3043 Command::new("git")
3045 .args(["checkout", "-b", "test-branch"])
3046 .current_dir(&repo_path)
3047 .output()
3048 .unwrap();
3049
3050 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
3052
3053 let safety_result = repo.check_checkout_safety("main");
3055 assert!(safety_result.is_ok());
3056 let safety_info = safety_result.unwrap();
3057 assert!(safety_info.is_some());
3058
3059 let info = safety_info.unwrap();
3060 assert!(!info.modified_files.is_empty());
3061 assert!(info.modified_files.contains(&"README.md".to_string()));
3062 }
3063
3064 #[test]
3065 fn test_unsafe_checkout_methods() {
3066 let (_temp_dir, repo_path) = create_test_repo();
3067 let repo = GitRepository::open(&repo_path).unwrap();
3068
3069 create_commit(&repo_path, "Second commit", "test.txt");
3071 Command::new("git")
3072 .args(["checkout", "-b", "test-branch"])
3073 .current_dir(&repo_path)
3074 .output()
3075 .unwrap();
3076
3077 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
3079
3080 let _result = repo.checkout_branch_unsafe("main");
3082 let head_commit = repo.get_head_commit().unwrap();
3087 let commit_hash = head_commit.id().to_string();
3088 let _result = repo.checkout_commit_unsafe(&commit_hash);
3089 }
3091
3092 #[test]
3093 fn test_get_modified_files() {
3094 let (_temp_dir, repo_path) = create_test_repo();
3095 let repo = GitRepository::open(&repo_path).unwrap();
3096
3097 let modified = repo.get_modified_files().unwrap();
3099 assert!(modified.is_empty());
3100
3101 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
3103
3104 let modified = repo.get_modified_files().unwrap();
3106 assert_eq!(modified.len(), 1);
3107 assert!(modified.contains(&"README.md".to_string()));
3108 }
3109
3110 #[test]
3111 fn test_get_staged_files() {
3112 let (_temp_dir, repo_path) = create_test_repo();
3113 let repo = GitRepository::open(&repo_path).unwrap();
3114
3115 let staged = repo.get_staged_files().unwrap();
3117 assert!(staged.is_empty());
3118
3119 std::fs::write(repo_path.join("staged.txt"), "Staged content").unwrap();
3121 Command::new("git")
3122 .args(["add", "staged.txt"])
3123 .current_dir(&repo_path)
3124 .output()
3125 .unwrap();
3126
3127 let staged = repo.get_staged_files().unwrap();
3129 assert_eq!(staged.len(), 1);
3130 assert!(staged.contains(&"staged.txt".to_string()));
3131 }
3132
3133 #[test]
3134 fn test_create_stash_fallback() {
3135 let (_temp_dir, repo_path) = create_test_repo();
3136 let repo = GitRepository::open(&repo_path).unwrap();
3137
3138 let result = repo.create_stash("test stash");
3140
3141 match result {
3143 Ok(stash_id) => {
3144 assert!(!stash_id.is_empty());
3146 assert!(stash_id.contains("stash") || stash_id.len() >= 7); }
3148 Err(error) => {
3149 let error_msg = error.to_string();
3151 assert!(
3152 error_msg.contains("No local changes to save")
3153 || error_msg.contains("git stash push")
3154 );
3155 }
3156 }
3157 }
3158
3159 #[test]
3160 fn test_delete_branch_unsafe() {
3161 let (_temp_dir, repo_path) = create_test_repo();
3162 let repo = GitRepository::open(&repo_path).unwrap();
3163
3164 create_commit(&repo_path, "Second commit", "test.txt");
3166 Command::new("git")
3167 .args(["checkout", "-b", "test-branch"])
3168 .current_dir(&repo_path)
3169 .output()
3170 .unwrap();
3171
3172 create_commit(&repo_path, "Branch-specific commit", "branch.txt");
3174
3175 Command::new("git")
3177 .args(["checkout", "main"])
3178 .current_dir(&repo_path)
3179 .output()
3180 .unwrap();
3181
3182 let result = repo.delete_branch_unsafe("test-branch");
3185 let _ = result; }
3189
3190 #[test]
3191 fn test_force_push_unsafe() {
3192 let (_temp_dir, repo_path) = create_test_repo();
3193 let repo = GitRepository::open(&repo_path).unwrap();
3194
3195 create_commit(&repo_path, "Second commit", "test.txt");
3197 Command::new("git")
3198 .args(["checkout", "-b", "test-branch"])
3199 .current_dir(&repo_path)
3200 .output()
3201 .unwrap();
3202
3203 let _result = repo.force_push_branch_unsafe("test-branch", "test-branch");
3206 }
3208
3209 #[test]
3210 fn test_cherry_pick_basic() {
3211 let (_temp_dir, repo_path) = create_test_repo();
3212 let repo = GitRepository::open(&repo_path).unwrap();
3213
3214 repo.create_branch("source", None).unwrap();
3216 repo.checkout_branch("source").unwrap();
3217
3218 std::fs::write(repo_path.join("cherry.txt"), "Cherry content").unwrap();
3219 Command::new("git")
3220 .args(["add", "."])
3221 .current_dir(&repo_path)
3222 .output()
3223 .unwrap();
3224
3225 Command::new("git")
3226 .args(["commit", "-m", "Cherry commit"])
3227 .current_dir(&repo_path)
3228 .output()
3229 .unwrap();
3230
3231 let cherry_commit = repo.get_head_commit_hash().unwrap();
3232
3233 Command::new("git")
3236 .args(["checkout", "-"])
3237 .current_dir(&repo_path)
3238 .output()
3239 .unwrap();
3240
3241 repo.create_branch("target", None).unwrap();
3242 repo.checkout_branch("target").unwrap();
3243
3244 let new_commit = repo.cherry_pick(&cherry_commit).unwrap();
3246
3247 assert_ne!(new_commit, cherry_commit, "Should create new commit hash");
3249
3250 assert!(
3252 repo_path.join("cherry.txt").exists(),
3253 "Cherry-picked file should exist"
3254 );
3255
3256 repo.checkout_branch("source").unwrap();
3258 let source_head = repo.get_head_commit_hash().unwrap();
3259 assert_eq!(
3260 source_head, cherry_commit,
3261 "Source branch should be unchanged"
3262 );
3263 }
3264
3265 #[test]
3266 fn test_cherry_pick_preserves_commit_message() {
3267 let (_temp_dir, repo_path) = create_test_repo();
3268 let repo = GitRepository::open(&repo_path).unwrap();
3269
3270 repo.create_branch("msg-test", None).unwrap();
3272 repo.checkout_branch("msg-test").unwrap();
3273
3274 std::fs::write(repo_path.join("msg.txt"), "Content").unwrap();
3275 Command::new("git")
3276 .args(["add", "."])
3277 .current_dir(&repo_path)
3278 .output()
3279 .unwrap();
3280
3281 let commit_msg = "Test: Special commit message\n\nWith body";
3282 Command::new("git")
3283 .args(["commit", "-m", commit_msg])
3284 .current_dir(&repo_path)
3285 .output()
3286 .unwrap();
3287
3288 let original_commit = repo.get_head_commit_hash().unwrap();
3289
3290 Command::new("git")
3292 .args(["checkout", "-"])
3293 .current_dir(&repo_path)
3294 .output()
3295 .unwrap();
3296 let new_commit = repo.cherry_pick(&original_commit).unwrap();
3297
3298 let output = Command::new("git")
3300 .args(["log", "-1", "--format=%B", &new_commit])
3301 .current_dir(&repo_path)
3302 .output()
3303 .unwrap();
3304
3305 let new_msg = String::from_utf8_lossy(&output.stdout);
3306 assert!(
3307 new_msg.contains("Special commit message"),
3308 "Should preserve commit message"
3309 );
3310 }
3311
3312 #[test]
3313 fn test_cherry_pick_handles_conflicts() {
3314 let (_temp_dir, repo_path) = create_test_repo();
3315 let repo = GitRepository::open(&repo_path).unwrap();
3316
3317 std::fs::write(repo_path.join("conflict.txt"), "Original").unwrap();
3319 Command::new("git")
3320 .args(["add", "."])
3321 .current_dir(&repo_path)
3322 .output()
3323 .unwrap();
3324
3325 Command::new("git")
3326 .args(["commit", "-m", "Add conflict file"])
3327 .current_dir(&repo_path)
3328 .output()
3329 .unwrap();
3330
3331 repo.create_branch("conflict-branch", None).unwrap();
3333 repo.checkout_branch("conflict-branch").unwrap();
3334
3335 std::fs::write(repo_path.join("conflict.txt"), "Modified").unwrap();
3336 Command::new("git")
3337 .args(["add", "."])
3338 .current_dir(&repo_path)
3339 .output()
3340 .unwrap();
3341
3342 Command::new("git")
3343 .args(["commit", "-m", "Modify conflict file"])
3344 .current_dir(&repo_path)
3345 .output()
3346 .unwrap();
3347
3348 let conflict_commit = repo.get_head_commit_hash().unwrap();
3349
3350 Command::new("git")
3353 .args(["checkout", "-"])
3354 .current_dir(&repo_path)
3355 .output()
3356 .unwrap();
3357 std::fs::write(repo_path.join("conflict.txt"), "Different").unwrap();
3358 Command::new("git")
3359 .args(["add", "."])
3360 .current_dir(&repo_path)
3361 .output()
3362 .unwrap();
3363
3364 Command::new("git")
3365 .args(["commit", "-m", "Different change"])
3366 .current_dir(&repo_path)
3367 .output()
3368 .unwrap();
3369
3370 let result = repo.cherry_pick(&conflict_commit);
3372 assert!(result.is_err(), "Cherry-pick with conflict should fail");
3373 }
3374
3375 #[test]
3376 fn test_reset_to_head_clears_staged_files() {
3377 let (_temp_dir, repo_path) = create_test_repo();
3378 let repo = GitRepository::open(&repo_path).unwrap();
3379
3380 std::fs::write(repo_path.join("staged1.txt"), "Content 1").unwrap();
3382 std::fs::write(repo_path.join("staged2.txt"), "Content 2").unwrap();
3383
3384 Command::new("git")
3385 .args(["add", "staged1.txt", "staged2.txt"])
3386 .current_dir(&repo_path)
3387 .output()
3388 .unwrap();
3389
3390 let staged_before = repo.get_staged_files().unwrap();
3392 assert_eq!(staged_before.len(), 2, "Should have 2 staged files");
3393
3394 repo.reset_to_head().unwrap();
3396
3397 let staged_after = repo.get_staged_files().unwrap();
3399 assert_eq!(
3400 staged_after.len(),
3401 0,
3402 "Should have no staged files after reset"
3403 );
3404 }
3405
3406 #[test]
3407 fn test_reset_to_head_clears_modified_files() {
3408 let (_temp_dir, repo_path) = create_test_repo();
3409 let repo = GitRepository::open(&repo_path).unwrap();
3410
3411 std::fs::write(repo_path.join("README.md"), "# Modified content").unwrap();
3413
3414 Command::new("git")
3416 .args(["add", "README.md"])
3417 .current_dir(&repo_path)
3418 .output()
3419 .unwrap();
3420
3421 assert!(repo.is_dirty().unwrap(), "Repo should be dirty");
3423
3424 repo.reset_to_head().unwrap();
3426
3427 assert!(
3429 !repo.is_dirty().unwrap(),
3430 "Repo should be clean after reset"
3431 );
3432
3433 let content = std::fs::read_to_string(repo_path.join("README.md")).unwrap();
3435 assert_eq!(
3436 content, "# Test",
3437 "File should be restored to original content"
3438 );
3439 }
3440
3441 #[test]
3442 fn test_reset_to_head_preserves_untracked_files() {
3443 let (_temp_dir, repo_path) = create_test_repo();
3444 let repo = GitRepository::open(&repo_path).unwrap();
3445
3446 std::fs::write(repo_path.join("untracked.txt"), "Untracked content").unwrap();
3448
3449 std::fs::write(repo_path.join("staged.txt"), "Staged content").unwrap();
3451 Command::new("git")
3452 .args(["add", "staged.txt"])
3453 .current_dir(&repo_path)
3454 .output()
3455 .unwrap();
3456
3457 repo.reset_to_head().unwrap();
3459
3460 assert!(
3462 repo_path.join("untracked.txt").exists(),
3463 "Untracked file should be preserved"
3464 );
3465
3466 assert!(
3468 !repo_path.join("staged.txt").exists(),
3469 "Staged but uncommitted file should be removed"
3470 );
3471 }
3472
3473 #[test]
3474 fn test_cherry_pick_does_not_modify_source() {
3475 let (_temp_dir, repo_path) = create_test_repo();
3476 let repo = GitRepository::open(&repo_path).unwrap();
3477
3478 repo.create_branch("feature", None).unwrap();
3480 repo.checkout_branch("feature").unwrap();
3481
3482 for i in 1..=3 {
3484 std::fs::write(
3485 repo_path.join(format!("file{i}.txt")),
3486 format!("Content {i}"),
3487 )
3488 .unwrap();
3489 Command::new("git")
3490 .args(["add", "."])
3491 .current_dir(&repo_path)
3492 .output()
3493 .unwrap();
3494
3495 Command::new("git")
3496 .args(["commit", "-m", &format!("Commit {i}")])
3497 .current_dir(&repo_path)
3498 .output()
3499 .unwrap();
3500 }
3501
3502 let source_commits = Command::new("git")
3504 .args(["log", "--format=%H", "feature"])
3505 .current_dir(&repo_path)
3506 .output()
3507 .unwrap();
3508 let source_state = String::from_utf8_lossy(&source_commits.stdout).to_string();
3509
3510 let commits: Vec<&str> = source_state.lines().collect();
3512 let middle_commit = commits[1];
3513
3514 Command::new("git")
3516 .args(["checkout", "-"])
3517 .current_dir(&repo_path)
3518 .output()
3519 .unwrap();
3520 repo.create_branch("target", None).unwrap();
3521 repo.checkout_branch("target").unwrap();
3522
3523 repo.cherry_pick(middle_commit).unwrap();
3524
3525 let after_commits = Command::new("git")
3527 .args(["log", "--format=%H", "feature"])
3528 .current_dir(&repo_path)
3529 .output()
3530 .unwrap();
3531 let after_state = String::from_utf8_lossy(&after_commits.stdout).to_string();
3532
3533 assert_eq!(
3534 source_state, after_state,
3535 "Source branch should be completely unchanged after cherry-pick"
3536 );
3537 }
3538
3539 #[test]
3540 fn test_detect_parent_branch() {
3541 let (_temp_dir, repo_path) = create_test_repo();
3542 let repo = GitRepository::open(&repo_path).unwrap();
3543
3544 repo.create_branch("dev123", None).unwrap();
3546 repo.checkout_branch("dev123").unwrap();
3547 create_commit(&repo_path, "Base commit on dev123", "base.txt");
3548
3549 repo.create_branch("feature-branch", None).unwrap();
3551 repo.checkout_branch("feature-branch").unwrap();
3552 create_commit(&repo_path, "Feature commit", "feature.txt");
3553
3554 let detected_parent = repo.detect_parent_branch().unwrap();
3556
3557 assert!(detected_parent.is_some(), "Should detect a parent branch");
3560
3561 let parent = detected_parent.unwrap();
3564 assert!(
3565 parent == "dev123" || parent == "main" || parent == "master",
3566 "Parent should be dev123, main, or master, got: {parent}"
3567 );
3568 }
3569}