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 update_branch_to_commit(&self, branch_name: &str, commit_id: &str) -> Result<()> {
294 let commit_oid = Oid::from_str(commit_id)
295 .map_err(|e| CascadeError::branch(format!("Invalid commit ID '{}': {}", commit_id, e)))?;
296
297 let commit = self.repo.find_commit(commit_oid)
298 .map_err(|e| CascadeError::branch(format!("Commit '{}' not found: {}", commit_id, e)))?;
299
300 if self.repo.find_branch(branch_name, git2::BranchType::Local).is_ok() {
302 let refname = format!("refs/heads/{}", branch_name);
304 self.repo.reference(&refname, commit_oid, true, "update branch to rebased commit")
305 .map_err(|e| CascadeError::branch(format!("Failed to update branch '{}': {}", branch_name, e)))?;
306 } else {
307 self.repo.branch(branch_name, &commit, false)
309 .map_err(|e| CascadeError::branch(format!("Failed to create branch '{}': {}", branch_name, e)))?;
310 }
311
312 Ok(())
313 }
314
315 pub fn force_push_single_branch(&self, branch_name: &str) -> Result<()> {
317 if let Err(e) = self.fetch() {
319 tracing::warn!("Could not fetch before force push: {}", e);
320 }
321
322 let safety_result = self.check_force_push_safety_enhanced(branch_name)?;
324 if let Some(backup_info) = safety_result {
325 self.create_backup_branch(branch_name, &backup_info.remote_commit_id)?;
326 }
327
328 let output = std::process::Command::new("git")
330 .args(["push", "--force", "origin", branch_name])
331 .current_dir(&self.path)
332 .output()
333 .map_err(|e| CascadeError::branch(format!("Failed to execute git push: {}", e)))?;
334
335 if !output.status.success() {
336 let stderr = String::from_utf8_lossy(&output.stderr);
337 return Err(CascadeError::branch(format!(
338 "Force push failed for '{}': {}",
339 branch_name, stderr
340 )));
341 }
342
343 Ok(())
344 }
345
346 pub fn checkout_branch(&self, name: &str) -> Result<()> {
348 self.checkout_branch_with_options(name, false)
349 }
350
351 pub fn checkout_branch_unsafe(&self, name: &str) -> Result<()> {
353 self.checkout_branch_with_options(name, true)
354 }
355
356 fn checkout_branch_with_options(&self, name: &str, force_unsafe: bool) -> Result<()> {
358 info!("Attempting to checkout branch: {}", name);
359
360 if !force_unsafe {
362 let safety_result = self.check_checkout_safety(name)?;
363 if let Some(safety_info) = safety_result {
364 self.handle_checkout_confirmation(name, &safety_info)?;
366 }
367 }
368
369 let branch = self
371 .repo
372 .find_branch(name, git2::BranchType::Local)
373 .map_err(|e| CascadeError::branch(format!("Could not find branch '{name}': {e}")))?;
374
375 let branch_ref = branch.get();
376 let tree = branch_ref.peel_to_tree().map_err(|e| {
377 CascadeError::branch(format!("Could not get tree for branch '{name}': {e}"))
378 })?;
379
380 self.repo
382 .checkout_tree(tree.as_object(), None)
383 .map_err(|e| {
384 CascadeError::branch(format!("Could not checkout branch '{name}': {e}"))
385 })?;
386
387 self.repo
389 .set_head(&format!("refs/heads/{name}"))
390 .map_err(|e| CascadeError::branch(format!("Could not update HEAD to '{name}': {e}")))?;
391
392 Output::success(format!("Switched to branch '{name}'"));
393 Ok(())
394 }
395
396 pub fn checkout_commit(&self, commit_hash: &str) -> Result<()> {
398 self.checkout_commit_with_options(commit_hash, false)
399 }
400
401 pub fn checkout_commit_unsafe(&self, commit_hash: &str) -> Result<()> {
403 self.checkout_commit_with_options(commit_hash, true)
404 }
405
406 fn checkout_commit_with_options(&self, commit_hash: &str, force_unsafe: bool) -> Result<()> {
408 info!("Attempting to checkout commit: {}", commit_hash);
409
410 if !force_unsafe {
412 let safety_result = self.check_checkout_safety(&format!("commit:{commit_hash}"))?;
413 if let Some(safety_info) = safety_result {
414 self.handle_checkout_confirmation(&format!("commit {commit_hash}"), &safety_info)?;
416 }
417 }
418
419 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
420
421 let commit = self.repo.find_commit(oid).map_err(|e| {
422 CascadeError::branch(format!("Could not find commit '{commit_hash}': {e}"))
423 })?;
424
425 let tree = commit.tree().map_err(|e| {
426 CascadeError::branch(format!(
427 "Could not get tree for commit '{commit_hash}': {e}"
428 ))
429 })?;
430
431 self.repo
433 .checkout_tree(tree.as_object(), None)
434 .map_err(|e| {
435 CascadeError::branch(format!("Could not checkout commit '{commit_hash}': {e}"))
436 })?;
437
438 self.repo.set_head_detached(oid).map_err(|e| {
440 CascadeError::branch(format!(
441 "Could not update HEAD to commit '{commit_hash}': {e}"
442 ))
443 })?;
444
445 Output::success(format!(
446 "Checked out commit '{commit_hash}' (detached HEAD)"
447 ));
448 Ok(())
449 }
450
451 pub fn branch_exists(&self, name: &str) -> bool {
453 self.repo.find_branch(name, git2::BranchType::Local).is_ok()
454 }
455
456 pub fn branch_exists_or_fetch(&self, name: &str) -> Result<bool> {
458 if self.repo.find_branch(name, git2::BranchType::Local).is_ok() {
460 return Ok(true);
461 }
462
463 println!("🔍 Branch '{name}' not found locally, trying to fetch from remote...");
465
466 use std::process::Command;
467
468 let fetch_result = Command::new("git")
470 .args(["fetch", "origin", &format!("{name}:{name}")])
471 .current_dir(&self.path)
472 .output();
473
474 match fetch_result {
475 Ok(output) => {
476 if output.status.success() {
477 println!("✅ Successfully fetched '{name}' from origin");
478 return Ok(self.repo.find_branch(name, git2::BranchType::Local).is_ok());
480 } else {
481 let stderr = String::from_utf8_lossy(&output.stderr);
482 tracing::debug!("Failed to fetch branch '{name}': {stderr}");
483 }
484 }
485 Err(e) => {
486 tracing::debug!("Git fetch command failed: {e}");
487 }
488 }
489
490 if name.contains('/') {
492 println!("🔍 Trying alternative fetch patterns...");
493
494 let fetch_all_result = Command::new("git")
496 .args(["fetch", "origin"])
497 .current_dir(&self.path)
498 .output();
499
500 if let Ok(output) = fetch_all_result {
501 if output.status.success() {
502 let checkout_result = Command::new("git")
504 .args(["checkout", "-b", name, &format!("origin/{name}")])
505 .current_dir(&self.path)
506 .output();
507
508 if let Ok(checkout_output) = checkout_result {
509 if checkout_output.status.success() {
510 println!(
511 "✅ Successfully created local branch '{name}' from origin/{name}"
512 );
513 return Ok(true);
514 }
515 }
516 }
517 }
518 }
519
520 Ok(false)
522 }
523
524 pub fn get_branch_commit_hash(&self, branch_name: &str) -> Result<String> {
526 let branch = self
527 .repo
528 .find_branch(branch_name, git2::BranchType::Local)
529 .map_err(|e| {
530 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
531 })?;
532
533 let commit = branch.get().peel_to_commit().map_err(|e| {
534 CascadeError::branch(format!(
535 "Could not get commit for branch '{branch_name}': {e}"
536 ))
537 })?;
538
539 Ok(commit.id().to_string())
540 }
541
542 pub fn list_branches(&self) -> Result<Vec<String>> {
544 let branches = self
545 .repo
546 .branches(Some(git2::BranchType::Local))
547 .map_err(CascadeError::Git)?;
548
549 let mut branch_names = Vec::new();
550 for branch in branches {
551 let (branch, _) = branch.map_err(CascadeError::Git)?;
552 if let Some(name) = branch.name().map_err(CascadeError::Git)? {
553 branch_names.push(name.to_string());
554 }
555 }
556
557 Ok(branch_names)
558 }
559
560 pub fn get_upstream_branch(&self, branch_name: &str) -> Result<Option<String>> {
562 let config = self.repo.config().map_err(CascadeError::Git)?;
564
565 let remote_key = format!("branch.{branch_name}.remote");
567 let merge_key = format!("branch.{branch_name}.merge");
568
569 if let (Ok(remote), Ok(merge_ref)) = (
570 config.get_string(&remote_key),
571 config.get_string(&merge_key),
572 ) {
573 if let Some(branch_part) = merge_ref.strip_prefix("refs/heads/") {
575 return Ok(Some(format!("{remote}/{branch_part}")));
576 }
577 }
578
579 let potential_upstream = format!("origin/{branch_name}");
581 if self
582 .repo
583 .find_reference(&format!("refs/remotes/{potential_upstream}"))
584 .is_ok()
585 {
586 return Ok(Some(potential_upstream));
587 }
588
589 Ok(None)
590 }
591
592 pub fn get_ahead_behind_counts(
594 &self,
595 local_branch: &str,
596 upstream_branch: &str,
597 ) -> Result<(usize, usize)> {
598 let local_ref = self
600 .repo
601 .find_reference(&format!("refs/heads/{local_branch}"))
602 .map_err(|_| {
603 CascadeError::config(format!("Local branch '{local_branch}' not found"))
604 })?;
605 let local_commit = local_ref.peel_to_commit().map_err(CascadeError::Git)?;
606
607 let upstream_ref = self
608 .repo
609 .find_reference(&format!("refs/remotes/{upstream_branch}"))
610 .map_err(|_| {
611 CascadeError::config(format!("Upstream branch '{upstream_branch}' not found"))
612 })?;
613 let upstream_commit = upstream_ref.peel_to_commit().map_err(CascadeError::Git)?;
614
615 let (ahead, behind) = self
617 .repo
618 .graph_ahead_behind(local_commit.id(), upstream_commit.id())
619 .map_err(CascadeError::Git)?;
620
621 Ok((ahead, behind))
622 }
623
624 pub fn set_upstream(&self, branch_name: &str, remote: &str, remote_branch: &str) -> Result<()> {
626 let mut config = self.repo.config().map_err(CascadeError::Git)?;
627
628 let remote_key = format!("branch.{branch_name}.remote");
630 config
631 .set_str(&remote_key, remote)
632 .map_err(CascadeError::Git)?;
633
634 let merge_key = format!("branch.{branch_name}.merge");
636 let merge_value = format!("refs/heads/{remote_branch}");
637 config
638 .set_str(&merge_key, &merge_value)
639 .map_err(CascadeError::Git)?;
640
641 Ok(())
642 }
643
644 pub fn commit(&self, message: &str) -> Result<String> {
646 self.validate_git_user_config()?;
648
649 let signature = self.get_signature()?;
650 let tree_id = self.get_index_tree()?;
651 let tree = self.repo.find_tree(tree_id).map_err(CascadeError::Git)?;
652
653 let head = self.repo.head().map_err(CascadeError::Git)?;
655 let parent_commit = head.peel_to_commit().map_err(CascadeError::Git)?;
656
657 let commit_id = self
658 .repo
659 .commit(
660 Some("HEAD"),
661 &signature,
662 &signature,
663 message,
664 &tree,
665 &[&parent_commit],
666 )
667 .map_err(CascadeError::Git)?;
668
669 Output::success(format!("Created commit: {commit_id} - {message}"));
670 Ok(commit_id.to_string())
671 }
672
673 pub fn commit_staged_changes(&self, default_message: &str) -> Result<Option<String>> {
675 let staged_files = self.get_staged_files()?;
677 if staged_files.is_empty() {
678 tracing::debug!("No staged changes to commit");
679 return Ok(None);
680 }
681
682 tracing::info!("Committing {} staged files", staged_files.len());
683 let commit_hash = self.commit(default_message)?;
684 Ok(Some(commit_hash))
685 }
686
687 pub fn stage_all(&self) -> Result<()> {
689 let mut index = self.repo.index().map_err(CascadeError::Git)?;
690
691 index
692 .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
693 .map_err(CascadeError::Git)?;
694
695 index.write().map_err(CascadeError::Git)?;
696
697 tracing::debug!("Staged all changes");
698 Ok(())
699 }
700
701 pub fn stage_files(&self, file_paths: &[&str]) -> Result<()> {
703 if file_paths.is_empty() {
704 tracing::debug!("No files to stage");
705 return Ok(());
706 }
707
708 let mut index = self.repo.index().map_err(CascadeError::Git)?;
709
710 for file_path in file_paths {
711 index
712 .add_path(std::path::Path::new(file_path))
713 .map_err(CascadeError::Git)?;
714 }
715
716 index.write().map_err(CascadeError::Git)?;
717
718 tracing::debug!(
719 "Staged {} specific files: {:?}",
720 file_paths.len(),
721 file_paths
722 );
723 Ok(())
724 }
725
726 pub fn stage_conflict_resolved_files(&self) -> Result<()> {
728 let conflicted_files = self.get_conflicted_files()?;
729 if conflicted_files.is_empty() {
730 tracing::debug!("No conflicted files to stage");
731 return Ok(());
732 }
733
734 let file_paths: Vec<&str> = conflicted_files.iter().map(|s| s.as_str()).collect();
735 self.stage_files(&file_paths)?;
736
737 tracing::debug!("Staged {} conflict-resolved files", conflicted_files.len());
738 Ok(())
739 }
740
741 pub fn path(&self) -> &Path {
743 &self.path
744 }
745
746 pub fn commit_exists(&self, commit_hash: &str) -> Result<bool> {
748 match Oid::from_str(commit_hash) {
749 Ok(oid) => match self.repo.find_commit(oid) {
750 Ok(_) => Ok(true),
751 Err(_) => Ok(false),
752 },
753 Err(_) => Ok(false),
754 }
755 }
756
757 pub fn get_head_commit(&self) -> Result<git2::Commit<'_>> {
759 let head = self
760 .repo
761 .head()
762 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
763 head.peel_to_commit()
764 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))
765 }
766
767 pub fn get_commit(&self, commit_hash: &str) -> Result<git2::Commit<'_>> {
769 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
770
771 self.repo.find_commit(oid).map_err(CascadeError::Git)
772 }
773
774 pub fn get_branch_head(&self, branch_name: &str) -> Result<String> {
776 let branch = self
777 .repo
778 .find_branch(branch_name, git2::BranchType::Local)
779 .map_err(|e| {
780 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
781 })?;
782
783 let commit = branch.get().peel_to_commit().map_err(|e| {
784 CascadeError::branch(format!(
785 "Could not get commit for branch '{branch_name}': {e}"
786 ))
787 })?;
788
789 Ok(commit.id().to_string())
790 }
791
792 pub fn validate_git_user_config(&self) -> Result<()> {
794 if let Ok(config) = self.repo.config() {
795 let name_result = config.get_string("user.name");
796 let email_result = config.get_string("user.email");
797
798 if let (Ok(name), Ok(email)) = (name_result, email_result) {
799 if !name.trim().is_empty() && !email.trim().is_empty() {
800 tracing::debug!("Git user config validated: {} <{}>", name, email);
801 return Ok(());
802 }
803 }
804 }
805
806 let is_ci = std::env::var("CI").is_ok();
808
809 if is_ci {
810 tracing::debug!("CI environment - skipping git user config validation");
811 return Ok(());
812 }
813
814 Output::warning("Git user configuration missing or incomplete");
815 Output::info("This can cause cherry-pick and commit operations to fail");
816 Output::info("Please configure git user information:");
817 Output::bullet("git config user.name \"Your Name\"".to_string());
818 Output::bullet("git config user.email \"your.email@example.com\"".to_string());
819 Output::info("Or set globally with the --global flag");
820
821 Ok(())
824 }
825
826 fn get_signature(&self) -> Result<Signature<'_>> {
828 if let Ok(config) = self.repo.config() {
830 let name_result = config.get_string("user.name");
832 let email_result = config.get_string("user.email");
833
834 if let (Ok(name), Ok(email)) = (name_result, email_result) {
835 if !name.trim().is_empty() && !email.trim().is_empty() {
836 tracing::debug!("Using git config: {} <{}>", name, email);
837 return Signature::now(&name, &email).map_err(CascadeError::Git);
838 }
839 } else {
840 tracing::debug!("Git user config incomplete or missing");
841 }
842 }
843
844 let is_ci = std::env::var("CI").is_ok();
846
847 if is_ci {
848 tracing::debug!("CI environment detected, using fallback signature");
849 return Signature::now("Cascade CLI", "cascade@example.com").map_err(CascadeError::Git);
850 }
851
852 tracing::warn!("Git user configuration missing - this can cause commit operations to fail");
854
855 match Signature::now("Cascade CLI", "cascade@example.com") {
857 Ok(sig) => {
858 Output::warning("Git user not configured - using fallback signature");
859 Output::info("For better git history, run:");
860 Output::bullet("git config user.name \"Your Name\"".to_string());
861 Output::bullet("git config user.email \"your.email@example.com\"".to_string());
862 Output::info("Or set it globally with --global flag");
863 Ok(sig)
864 }
865 Err(e) => {
866 Err(CascadeError::branch(format!(
867 "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\""
868 )))
869 }
870 }
871 }
872
873 fn configure_remote_callbacks(&self) -> Result<git2::RemoteCallbacks<'_>> {
876 self.configure_remote_callbacks_with_fallback(false)
877 }
878
879 fn should_retry_with_default_credentials(&self, error: &git2::Error) -> bool {
881 match error.class() {
882 git2::ErrorClass::Http => {
884 match error.code() {
886 git2::ErrorCode::Auth => true,
887 _ => {
888 let error_string = error.to_string();
890 error_string.contains("too many redirects")
891 || error_string.contains("authentication replays")
892 || error_string.contains("authentication required")
893 }
894 }
895 }
896 git2::ErrorClass::Net => {
897 let error_string = error.to_string();
899 error_string.contains("authentication")
900 || error_string.contains("unauthorized")
901 || error_string.contains("forbidden")
902 }
903 _ => false,
904 }
905 }
906
907 fn should_fallback_to_git_cli(&self, error: &git2::Error) -> bool {
909 match error.class() {
910 git2::ErrorClass::Ssl => true,
912
913 git2::ErrorClass::Http if error.code() == git2::ErrorCode::Certificate => true,
915
916 git2::ErrorClass::Ssh => {
918 let error_string = error.to_string();
919 error_string.contains("no callback set")
920 || error_string.contains("authentication required")
921 }
922
923 git2::ErrorClass::Net => {
925 let error_string = error.to_string();
926 error_string.contains("TLS stream")
927 || error_string.contains("SSL")
928 || error_string.contains("proxy")
929 || error_string.contains("firewall")
930 }
931
932 git2::ErrorClass::Http => {
934 let error_string = error.to_string();
935 error_string.contains("TLS stream")
936 || error_string.contains("SSL")
937 || error_string.contains("proxy")
938 }
939
940 _ => false,
941 }
942 }
943
944 fn configure_remote_callbacks_with_fallback(
945 &self,
946 use_default_first: bool,
947 ) -> Result<git2::RemoteCallbacks<'_>> {
948 let mut callbacks = git2::RemoteCallbacks::new();
949
950 let bitbucket_credentials = self.bitbucket_credentials.clone();
952 callbacks.credentials(move |url, username_from_url, allowed_types| {
953 tracing::debug!(
954 "Authentication requested for URL: {}, username: {:?}, allowed_types: {:?}",
955 url,
956 username_from_url,
957 allowed_types
958 );
959
960 if allowed_types.contains(git2::CredentialType::SSH_KEY) {
962 if let Some(username) = username_from_url {
963 tracing::debug!("Trying SSH key authentication for user: {}", username);
964 return git2::Cred::ssh_key_from_agent(username);
965 }
966 }
967
968 if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
970 if use_default_first {
972 tracing::debug!("Corporate network mode: trying DefaultCredentials first");
973 return git2::Cred::default();
974 }
975
976 if url.contains("bitbucket") {
977 if let Some(creds) = &bitbucket_credentials {
978 if let (Some(username), Some(token)) = (&creds.username, &creds.token) {
980 tracing::debug!("Trying Bitbucket username + token authentication");
981 return git2::Cred::userpass_plaintext(username, token);
982 }
983
984 if let Some(token) = &creds.token {
986 tracing::debug!("Trying Bitbucket token-as-username authentication");
987 return git2::Cred::userpass_plaintext(token, "");
988 }
989
990 if let Some(username) = &creds.username {
992 tracing::debug!("Trying Bitbucket username authentication (will use credential helper)");
993 return git2::Cred::username(username);
994 }
995 }
996 }
997
998 tracing::debug!("Trying default credential helper for HTTPS authentication");
1000 return git2::Cred::default();
1001 }
1002
1003 tracing::debug!("Using default credential fallback");
1005 git2::Cred::default()
1006 });
1007
1008 let mut ssl_configured = false;
1013
1014 if let Some(ssl_config) = &self.ssl_config {
1016 if ssl_config.accept_invalid_certs {
1017 Output::warning(
1018 "SSL certificate verification DISABLED via Cascade config - this is insecure!",
1019 );
1020 callbacks.certificate_check(|_cert, _host| {
1021 tracing::debug!("⚠️ Accepting invalid certificate for host: {}", _host);
1022 Ok(git2::CertificateCheckStatus::CertificateOk)
1023 });
1024 ssl_configured = true;
1025 } else if let Some(ca_path) = &ssl_config.ca_bundle_path {
1026 Output::info(format!(
1027 "Using custom CA bundle from Cascade config: {ca_path}"
1028 ));
1029 callbacks.certificate_check(|_cert, host| {
1030 tracing::debug!("Using custom CA bundle for host: {}", host);
1031 Ok(git2::CertificateCheckStatus::CertificateOk)
1032 });
1033 ssl_configured = true;
1034 }
1035 }
1036
1037 if !ssl_configured {
1039 if let Ok(config) = self.repo.config() {
1040 let ssl_verify = config.get_bool("http.sslVerify").unwrap_or(true);
1041
1042 if !ssl_verify {
1043 Output::warning(
1044 "SSL certificate verification DISABLED via git config - this is insecure!",
1045 );
1046 callbacks.certificate_check(|_cert, host| {
1047 tracing::debug!("⚠️ Bypassing SSL verification for host: {}", host);
1048 Ok(git2::CertificateCheckStatus::CertificateOk)
1049 });
1050 ssl_configured = true;
1051 } else if let Ok(ca_path) = config.get_string("http.sslCAInfo") {
1052 Output::info(format!("Using custom CA bundle from git config: {ca_path}"));
1053 callbacks.certificate_check(|_cert, host| {
1054 tracing::debug!("Using git config CA bundle for host: {}", host);
1055 Ok(git2::CertificateCheckStatus::CertificateOk)
1056 });
1057 ssl_configured = true;
1058 }
1059 }
1060 }
1061
1062 if !ssl_configured {
1065 tracing::debug!(
1066 "Using system certificate store for SSL verification (default behavior)"
1067 );
1068
1069 if cfg!(target_os = "macos") {
1071 tracing::debug!("macOS detected - using default certificate validation");
1072 } else {
1075 callbacks.certificate_check(|_cert, host| {
1077 tracing::debug!("System certificate validation for host: {}", host);
1078 Ok(git2::CertificateCheckStatus::CertificatePassthrough)
1079 });
1080 }
1081 }
1082
1083 Ok(callbacks)
1084 }
1085
1086 fn get_index_tree(&self) -> Result<Oid> {
1088 let mut index = self.repo.index().map_err(CascadeError::Git)?;
1089
1090 index.write_tree().map_err(CascadeError::Git)
1091 }
1092
1093 pub fn get_status(&self) -> Result<git2::Statuses<'_>> {
1095 self.repo.statuses(None).map_err(CascadeError::Git)
1096 }
1097
1098 pub fn get_status_summary(&self) -> Result<GitStatusSummary> {
1100 let statuses = self.get_status()?;
1101
1102 let mut staged_files = 0;
1103 let mut unstaged_files = 0;
1104 let mut untracked_files = 0;
1105
1106 for status in statuses.iter() {
1107 let flags = status.status();
1108
1109 if flags.intersects(
1110 git2::Status::INDEX_MODIFIED
1111 | git2::Status::INDEX_NEW
1112 | git2::Status::INDEX_DELETED
1113 | git2::Status::INDEX_RENAMED
1114 | git2::Status::INDEX_TYPECHANGE,
1115 ) {
1116 staged_files += 1;
1117 }
1118
1119 if flags.intersects(
1120 git2::Status::WT_MODIFIED
1121 | git2::Status::WT_DELETED
1122 | git2::Status::WT_TYPECHANGE
1123 | git2::Status::WT_RENAMED,
1124 ) {
1125 unstaged_files += 1;
1126 }
1127
1128 if flags.intersects(git2::Status::WT_NEW) {
1129 untracked_files += 1;
1130 }
1131 }
1132
1133 Ok(GitStatusSummary {
1134 staged_files,
1135 unstaged_files,
1136 untracked_files,
1137 })
1138 }
1139
1140 pub fn get_current_commit_hash(&self) -> Result<String> {
1142 self.get_head_commit_hash()
1143 }
1144
1145 pub fn get_commit_count_between(&self, from_commit: &str, to_commit: &str) -> Result<usize> {
1147 let from_oid = git2::Oid::from_str(from_commit).map_err(CascadeError::Git)?;
1148 let to_oid = git2::Oid::from_str(to_commit).map_err(CascadeError::Git)?;
1149
1150 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
1151 revwalk.push(to_oid).map_err(CascadeError::Git)?;
1152 revwalk.hide(from_oid).map_err(CascadeError::Git)?;
1153
1154 Ok(revwalk.count())
1155 }
1156
1157 pub fn get_remote_url(&self, name: &str) -> Result<String> {
1159 let remote = self.repo.find_remote(name).map_err(CascadeError::Git)?;
1160 Ok(remote.url().unwrap_or("unknown").to_string())
1161 }
1162
1163 pub fn cherry_pick(&self, commit_hash: &str) -> Result<String> {
1165 tracing::debug!("Cherry-picking commit {}", commit_hash);
1166
1167 self.validate_git_user_config()?;
1169
1170 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
1171 let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
1172
1173 let commit_tree = commit.tree().map_err(CascadeError::Git)?;
1175
1176 let parent_commit = if commit.parent_count() > 0 {
1178 commit.parent(0).map_err(CascadeError::Git)?
1179 } else {
1180 let empty_tree_oid = self.repo.treebuilder(None)?.write()?;
1182 let empty_tree = self.repo.find_tree(empty_tree_oid)?;
1183 let sig = self.get_signature()?;
1184 return self
1185 .repo
1186 .commit(
1187 Some("HEAD"),
1188 &sig,
1189 &sig,
1190 commit.message().unwrap_or("Cherry-picked commit"),
1191 &empty_tree,
1192 &[],
1193 )
1194 .map(|oid| oid.to_string())
1195 .map_err(CascadeError::Git);
1196 };
1197
1198 let parent_tree = parent_commit.tree().map_err(CascadeError::Git)?;
1199
1200 let head_commit = self.get_head_commit()?;
1202 let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
1203
1204 let mut index = self
1206 .repo
1207 .merge_trees(&parent_tree, &head_tree, &commit_tree, None)
1208 .map_err(CascadeError::Git)?;
1209
1210 if index.has_conflicts() {
1212 return Err(CascadeError::branch(format!(
1213 "Cherry-pick of {commit_hash} has conflicts that need manual resolution"
1214 )));
1215 }
1216
1217 let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
1219 let merged_tree = self
1220 .repo
1221 .find_tree(merged_tree_oid)
1222 .map_err(CascadeError::Git)?;
1223
1224 let signature = self.get_signature()?;
1226 let message = format!("Cherry-pick: {}", commit.message().unwrap_or(""));
1227
1228 let new_commit_oid = self
1229 .repo
1230 .commit(
1231 Some("HEAD"),
1232 &signature,
1233 &signature,
1234 &message,
1235 &merged_tree,
1236 &[&head_commit],
1237 )
1238 .map_err(CascadeError::Git)?;
1239
1240 let new_commit = self
1242 .repo
1243 .find_commit(new_commit_oid)
1244 .map_err(CascadeError::Git)?;
1245 let new_tree = new_commit.tree().map_err(CascadeError::Git)?;
1246
1247 self.repo
1248 .checkout_tree(
1249 new_tree.as_object(),
1250 Some(git2::build::CheckoutBuilder::new().force()),
1251 )
1252 .map_err(CascadeError::Git)?;
1253
1254 tracing::info!("Cherry-picked {} -> {}", commit_hash, new_commit_oid);
1255 Ok(new_commit_oid.to_string())
1256 }
1257
1258 pub fn has_conflicts(&self) -> Result<bool> {
1260 let index = self.repo.index().map_err(CascadeError::Git)?;
1261 Ok(index.has_conflicts())
1262 }
1263
1264 pub fn get_conflicted_files(&self) -> Result<Vec<String>> {
1266 let index = self.repo.index().map_err(CascadeError::Git)?;
1267
1268 let mut conflicts = Vec::new();
1269
1270 let conflict_iter = index.conflicts().map_err(CascadeError::Git)?;
1272
1273 for conflict in conflict_iter {
1274 let conflict = conflict.map_err(CascadeError::Git)?;
1275 if let Some(our) = conflict.our {
1276 if let Ok(path) = std::str::from_utf8(&our.path) {
1277 conflicts.push(path.to_string());
1278 }
1279 } else if let Some(their) = conflict.their {
1280 if let Ok(path) = std::str::from_utf8(&their.path) {
1281 conflicts.push(path.to_string());
1282 }
1283 }
1284 }
1285
1286 Ok(conflicts)
1287 }
1288
1289 pub fn fetch(&self) -> Result<()> {
1291 tracing::info!("Fetching from origin");
1292
1293 let mut remote = self
1294 .repo
1295 .find_remote("origin")
1296 .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
1297
1298 let callbacks = self.configure_remote_callbacks()?;
1300
1301 let mut fetch_options = git2::FetchOptions::new();
1303 fetch_options.remote_callbacks(callbacks);
1304
1305 match remote.fetch::<&str>(&[], Some(&mut fetch_options), None) {
1307 Ok(_) => {
1308 tracing::debug!("Fetch completed successfully");
1309 Ok(())
1310 }
1311 Err(e) => {
1312 if self.should_retry_with_default_credentials(&e) {
1313 tracing::debug!(
1314 "Authentication error detected (class: {:?}, code: {:?}): {}, retrying with DefaultCredentials",
1315 e.class(), e.code(), e
1316 );
1317
1318 let callbacks = self.configure_remote_callbacks_with_fallback(true)?;
1320 let mut fetch_options = git2::FetchOptions::new();
1321 fetch_options.remote_callbacks(callbacks);
1322
1323 match remote.fetch::<&str>(&[], Some(&mut fetch_options), None) {
1324 Ok(_) => {
1325 tracing::debug!("Fetch succeeded with DefaultCredentials");
1326 return Ok(());
1327 }
1328 Err(retry_error) => {
1329 tracing::debug!(
1330 "DefaultCredentials retry failed: {}, falling back to git CLI",
1331 retry_error
1332 );
1333 return self.fetch_with_git_cli();
1334 }
1335 }
1336 }
1337
1338 if self.should_fallback_to_git_cli(&e) {
1339 tracing::debug!(
1340 "Network/SSL error detected (class: {:?}, code: {:?}): {}, falling back to git CLI for fetch operation",
1341 e.class(), e.code(), e
1342 );
1343 return self.fetch_with_git_cli();
1344 }
1345 Err(CascadeError::Git(e))
1346 }
1347 }
1348 }
1349
1350 pub fn pull(&self, branch: &str) -> Result<()> {
1352 tracing::info!("Pulling branch: {}", branch);
1353
1354 match self.fetch() {
1356 Ok(_) => {}
1357 Err(e) => {
1358 let error_string = e.to_string();
1360 if error_string.contains("TLS stream") || error_string.contains("SSL") {
1361 tracing::warn!(
1362 "git2 error detected: {}, falling back to git CLI for pull operation",
1363 e
1364 );
1365 return self.pull_with_git_cli(branch);
1366 }
1367 return Err(e);
1368 }
1369 }
1370
1371 let remote_branch_name = format!("origin/{branch}");
1373 let remote_oid = self
1374 .repo
1375 .refname_to_id(&format!("refs/remotes/{remote_branch_name}"))
1376 .map_err(|e| {
1377 CascadeError::branch(format!("Remote branch {remote_branch_name} not found: {e}"))
1378 })?;
1379
1380 let remote_commit = self
1381 .repo
1382 .find_commit(remote_oid)
1383 .map_err(CascadeError::Git)?;
1384
1385 let head_commit = self.get_head_commit()?;
1387
1388 if head_commit.id() == remote_commit.id() {
1390 tracing::debug!("Already up to date");
1391 return Ok(());
1392 }
1393
1394 let merge_base_oid = self
1396 .repo
1397 .merge_base(head_commit.id(), remote_commit.id())
1398 .map_err(CascadeError::Git)?;
1399
1400 if merge_base_oid == head_commit.id() {
1401 tracing::debug!("Fast-forwarding {} to {}", branch, remote_commit.id());
1403
1404 let refname = format!("refs/heads/{}", branch);
1406 self.repo.reference(&refname, remote_oid, true, "pull: Fast-forward")
1407 .map_err(CascadeError::Git)?;
1408
1409 self.repo.set_head(&refname).map_err(CascadeError::Git)?;
1411
1412 self.repo.checkout_head(Some(
1414 git2::build::CheckoutBuilder::new()
1415 .force()
1416 .remove_untracked(false)
1417 )).map_err(CascadeError::Git)?;
1418
1419 tracing::info!("Fast-forwarded to {}", remote_commit.id());
1420 return Ok(());
1421 }
1422
1423 Err(CascadeError::branch(format!(
1426 "Branch '{}' has diverged from remote. Local has commits not in remote. \
1427 Protected branches should not have local commits. \
1428 Try: git reset --hard origin/{}",
1429 branch, branch
1430 )))
1431 }
1432
1433 pub fn push(&self, branch: &str) -> Result<()> {
1435 let mut remote = self
1438 .repo
1439 .find_remote("origin")
1440 .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
1441
1442 let remote_url = remote.url().unwrap_or("unknown").to_string();
1443 tracing::debug!("Remote URL: {}", remote_url);
1444
1445 let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
1446 tracing::debug!("Push refspec: {}", refspec);
1447
1448 let mut callbacks = self.configure_remote_callbacks()?;
1450
1451 callbacks.push_update_reference(|refname, status| {
1453 if let Some(msg) = status {
1454 tracing::error!("Push failed for ref {}: {}", refname, msg);
1455 return Err(git2::Error::from_str(&format!("Push failed: {msg}")));
1456 }
1457 tracing::debug!("Push succeeded for ref: {}", refname);
1458 Ok(())
1459 });
1460
1461 let mut push_options = git2::PushOptions::new();
1463 push_options.remote_callbacks(callbacks);
1464
1465 match remote.push(&[&refspec], Some(&mut push_options)) {
1467 Ok(_) => {
1468 tracing::info!("Push completed successfully for branch: {}", branch);
1469 Ok(())
1470 }
1471 Err(e) => {
1472 tracing::debug!(
1473 "git2 push error: {} (class: {:?}, code: {:?})",
1474 e,
1475 e.class(),
1476 e.code()
1477 );
1478
1479 if self.should_retry_with_default_credentials(&e) {
1480 tracing::debug!(
1481 "Authentication error detected (class: {:?}, code: {:?}): {}, retrying with DefaultCredentials",
1482 e.class(), e.code(), e
1483 );
1484
1485 let callbacks = self.configure_remote_callbacks_with_fallback(true)?;
1487 let mut push_options = git2::PushOptions::new();
1488 push_options.remote_callbacks(callbacks);
1489
1490 match remote.push(&[&refspec], Some(&mut push_options)) {
1491 Ok(_) => {
1492 tracing::debug!("Push succeeded with DefaultCredentials");
1493 return Ok(());
1494 }
1495 Err(retry_error) => {
1496 tracing::debug!(
1497 "DefaultCredentials retry failed: {}, falling back to git CLI",
1498 retry_error
1499 );
1500 return self.push_with_git_cli(branch);
1501 }
1502 }
1503 }
1504
1505 if self.should_fallback_to_git_cli(&e) {
1506 tracing::debug!(
1507 "Network/SSL error detected (class: {:?}, code: {:?}): {}, falling back to git CLI for push operation",
1508 e.class(), e.code(), e
1509 );
1510 return self.push_with_git_cli(branch);
1511 }
1512
1513 let error_msg = if e.to_string().contains("authentication") {
1515 format!(
1516 "Authentication failed for branch '{branch}'. Try: git push origin {branch}"
1517 )
1518 } else {
1519 format!("Failed to push branch '{branch}': {e}")
1520 };
1521
1522 tracing::error!("{}", error_msg);
1523 Err(CascadeError::branch(error_msg))
1524 }
1525 }
1526 }
1527
1528 fn push_with_git_cli(&self, branch: &str) -> Result<()> {
1531 let output = std::process::Command::new("git")
1532 .args(["push", "origin", branch])
1533 .current_dir(&self.path)
1534 .output()
1535 .map_err(|e| CascadeError::branch(format!("Failed to execute git command: {e}")))?;
1536
1537 if output.status.success() {
1538 Ok(())
1540 } else {
1541 let stderr = String::from_utf8_lossy(&output.stderr);
1542 let _stdout = String::from_utf8_lossy(&output.stdout);
1543 let error_msg = if stderr.contains("SSL_connect") || stderr.contains("SSL_ERROR") {
1545 "Network error: Unable to connect to repository (VPN may be required)".to_string()
1546 } else if stderr.contains("repository") && stderr.contains("not found") {
1547 "Repository not found - check your Bitbucket configuration".to_string()
1548 } else if stderr.contains("authentication") || stderr.contains("403") {
1549 "Authentication failed - check your credentials".to_string()
1550 } else {
1551 stderr.trim().to_string()
1553 };
1554 tracing::error!("{}", error_msg);
1555 Err(CascadeError::branch(error_msg))
1556 }
1557 }
1558
1559 fn fetch_with_git_cli(&self) -> Result<()> {
1562 tracing::info!("Using git CLI fallback for fetch operation");
1563
1564 let output = std::process::Command::new("git")
1565 .args(["fetch", "origin"])
1566 .current_dir(&self.path)
1567 .output()
1568 .map_err(|e| {
1569 CascadeError::Git(git2::Error::from_str(&format!(
1570 "Failed to execute git command: {e}"
1571 )))
1572 })?;
1573
1574 if output.status.success() {
1575 tracing::info!("✅ Git CLI fetch succeeded");
1576 Ok(())
1577 } else {
1578 let stderr = String::from_utf8_lossy(&output.stderr);
1579 let stdout = String::from_utf8_lossy(&output.stdout);
1580 let error_msg = format!(
1581 "Git CLI fetch failed: {}\nStdout: {}\nStderr: {}",
1582 output.status, stdout, stderr
1583 );
1584 tracing::error!("{}", error_msg);
1585 Err(CascadeError::Git(git2::Error::from_str(&error_msg)))
1586 }
1587 }
1588
1589 fn pull_with_git_cli(&self, branch: &str) -> Result<()> {
1592 tracing::info!("Using git CLI fallback for pull operation: {}", branch);
1593
1594 let output = std::process::Command::new("git")
1595 .args(["pull", "origin", branch])
1596 .current_dir(&self.path)
1597 .output()
1598 .map_err(|e| {
1599 CascadeError::Git(git2::Error::from_str(&format!(
1600 "Failed to execute git command: {e}"
1601 )))
1602 })?;
1603
1604 if output.status.success() {
1605 tracing::info!("✅ Git CLI pull succeeded for branch: {}", branch);
1606 Ok(())
1607 } else {
1608 let stderr = String::from_utf8_lossy(&output.stderr);
1609 let stdout = String::from_utf8_lossy(&output.stdout);
1610 let error_msg = format!(
1611 "Git CLI pull failed for branch '{}': {}\nStdout: {}\nStderr: {}",
1612 branch, output.status, stdout, stderr
1613 );
1614 tracing::error!("{}", error_msg);
1615 Err(CascadeError::Git(git2::Error::from_str(&error_msg)))
1616 }
1617 }
1618
1619 fn force_push_with_git_cli(&self, branch: &str) -> Result<()> {
1622 tracing::info!(
1623 "Using git CLI fallback for force push operation: {}",
1624 branch
1625 );
1626
1627 let output = std::process::Command::new("git")
1628 .args(["push", "--force", "origin", branch])
1629 .current_dir(&self.path)
1630 .output()
1631 .map_err(|e| CascadeError::branch(format!("Failed to execute git command: {e}")))?;
1632
1633 if output.status.success() {
1634 tracing::info!("✅ Git CLI force push succeeded for branch: {}", branch);
1635 Ok(())
1636 } else {
1637 let stderr = String::from_utf8_lossy(&output.stderr);
1638 let stdout = String::from_utf8_lossy(&output.stdout);
1639 let error_msg = format!(
1640 "Git CLI force push failed for branch '{}': {}\nStdout: {}\nStderr: {}",
1641 branch, output.status, stdout, stderr
1642 );
1643 tracing::error!("{}", error_msg);
1644 Err(CascadeError::branch(error_msg))
1645 }
1646 }
1647
1648 pub fn delete_branch(&self, name: &str) -> Result<()> {
1650 self.delete_branch_with_options(name, false)
1651 }
1652
1653 pub fn delete_branch_unsafe(&self, name: &str) -> Result<()> {
1655 self.delete_branch_with_options(name, true)
1656 }
1657
1658 fn delete_branch_with_options(&self, name: &str, force_unsafe: bool) -> Result<()> {
1660 info!("Attempting to delete branch: {}", name);
1661
1662 if !force_unsafe {
1664 let safety_result = self.check_branch_deletion_safety(name)?;
1665 if let Some(safety_info) = safety_result {
1666 self.handle_branch_deletion_confirmation(name, &safety_info)?;
1668 }
1669 }
1670
1671 let mut branch = self
1672 .repo
1673 .find_branch(name, git2::BranchType::Local)
1674 .map_err(|e| CascadeError::branch(format!("Could not find branch '{name}': {e}")))?;
1675
1676 branch
1677 .delete()
1678 .map_err(|e| CascadeError::branch(format!("Could not delete branch '{name}': {e}")))?;
1679
1680 info!("Successfully deleted branch '{}'", name);
1681 Ok(())
1682 }
1683
1684 pub fn get_commits_between(&self, from: &str, to: &str) -> Result<Vec<git2::Commit<'_>>> {
1686 let from_oid = self
1687 .repo
1688 .refname_to_id(&format!("refs/heads/{from}"))
1689 .or_else(|_| Oid::from_str(from))
1690 .map_err(|e| CascadeError::branch(format!("Invalid from reference '{from}': {e}")))?;
1691
1692 let to_oid = self
1693 .repo
1694 .refname_to_id(&format!("refs/heads/{to}"))
1695 .or_else(|_| Oid::from_str(to))
1696 .map_err(|e| CascadeError::branch(format!("Invalid to reference '{to}': {e}")))?;
1697
1698 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
1699
1700 revwalk.push(to_oid).map_err(CascadeError::Git)?;
1701 revwalk.hide(from_oid).map_err(CascadeError::Git)?;
1702
1703 let mut commits = Vec::new();
1704 for oid in revwalk {
1705 let oid = oid.map_err(CascadeError::Git)?;
1706 let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
1707 commits.push(commit);
1708 }
1709
1710 Ok(commits)
1711 }
1712
1713 pub fn force_push_branch(&self, target_branch: &str, source_branch: &str) -> Result<()> {
1716 self.force_push_branch_with_options(target_branch, source_branch, false)
1717 }
1718
1719 pub fn force_push_branch_unsafe(&self, target_branch: &str, source_branch: &str) -> Result<()> {
1721 self.force_push_branch_with_options(target_branch, source_branch, true)
1722 }
1723
1724 fn force_push_branch_with_options(
1726 &self,
1727 target_branch: &str,
1728 source_branch: &str,
1729 force_unsafe: bool,
1730 ) -> Result<()> {
1731 info!(
1732 "Force pushing {} content to {} to preserve PR history",
1733 source_branch, target_branch
1734 );
1735
1736 if !force_unsafe {
1738 let safety_result = self.check_force_push_safety_enhanced(target_branch)?;
1739 if let Some(backup_info) = safety_result {
1740 self.create_backup_branch(target_branch, &backup_info.remote_commit_id)?;
1742 info!(
1743 "✅ Created backup branch: {}",
1744 backup_info.backup_branch_name
1745 );
1746 }
1747 }
1748
1749 let source_ref = self
1751 .repo
1752 .find_reference(&format!("refs/heads/{source_branch}"))
1753 .map_err(|e| {
1754 CascadeError::config(format!("Failed to find source branch {source_branch}: {e}"))
1755 })?;
1756 let _source_commit = source_ref.peel_to_commit().map_err(|e| {
1757 CascadeError::config(format!(
1758 "Failed to get commit for source branch {source_branch}: {e}"
1759 ))
1760 })?;
1761
1762 let mut remote = self
1764 .repo
1765 .find_remote("origin")
1766 .map_err(|e| CascadeError::config(format!("Failed to find origin remote: {e}")))?;
1767
1768 let refspec = format!("+refs/heads/{source_branch}:refs/heads/{target_branch}");
1770
1771 let callbacks = self.configure_remote_callbacks()?;
1773
1774 let mut push_options = git2::PushOptions::new();
1776 push_options.remote_callbacks(callbacks);
1777
1778 match remote.push(&[&refspec], Some(&mut push_options)) {
1779 Ok(_) => {}
1780 Err(e) => {
1781 if self.should_retry_with_default_credentials(&e) {
1782 tracing::debug!(
1783 "Authentication error detected (class: {:?}, code: {:?}): {}, retrying with DefaultCredentials",
1784 e.class(), e.code(), e
1785 );
1786
1787 let callbacks = self.configure_remote_callbacks_with_fallback(true)?;
1789 let mut push_options = git2::PushOptions::new();
1790 push_options.remote_callbacks(callbacks);
1791
1792 match remote.push(&[&refspec], Some(&mut push_options)) {
1793 Ok(_) => {
1794 tracing::debug!("Force push succeeded with DefaultCredentials");
1795 }
1797 Err(retry_error) => {
1798 tracing::debug!(
1799 "DefaultCredentials retry failed: {}, falling back to git CLI",
1800 retry_error
1801 );
1802 return self.force_push_with_git_cli(target_branch);
1803 }
1804 }
1805 } else if self.should_fallback_to_git_cli(&e) {
1806 tracing::debug!(
1807 "Network/SSL error detected (class: {:?}, code: {:?}): {}, falling back to git CLI for force push operation",
1808 e.class(), e.code(), e
1809 );
1810 return self.force_push_with_git_cli(target_branch);
1811 } else {
1812 return Err(CascadeError::config(format!(
1813 "Failed to force push {target_branch}: {e}"
1814 )));
1815 }
1816 }
1817 }
1818
1819 info!(
1820 "✅ Successfully force pushed {} to preserve PR history",
1821 target_branch
1822 );
1823 Ok(())
1824 }
1825
1826 fn check_force_push_safety_enhanced(
1829 &self,
1830 target_branch: &str,
1831 ) -> Result<Option<ForceBackupInfo>> {
1832 match self.fetch() {
1834 Ok(_) => {}
1835 Err(e) => {
1836 warn!("Could not fetch latest changes for safety check: {}", e);
1838 }
1839 }
1840
1841 let remote_ref = format!("refs/remotes/origin/{target_branch}");
1843 let local_ref = format!("refs/heads/{target_branch}");
1844
1845 let local_commit = match self.repo.find_reference(&local_ref) {
1847 Ok(reference) => reference.peel_to_commit().ok(),
1848 Err(_) => None,
1849 };
1850
1851 let remote_commit = match self.repo.find_reference(&remote_ref) {
1852 Ok(reference) => reference.peel_to_commit().ok(),
1853 Err(_) => None,
1854 };
1855
1856 if let (Some(local), Some(remote)) = (local_commit, remote_commit) {
1858 if local.id() != remote.id() {
1859 let merge_base_oid = self
1861 .repo
1862 .merge_base(local.id(), remote.id())
1863 .map_err(|e| CascadeError::config(format!("Failed to find merge base: {e}")))?;
1864
1865 if merge_base_oid != remote.id() {
1867 let commits_to_lose = self.count_commits_between(
1868 &merge_base_oid.to_string(),
1869 &remote.id().to_string(),
1870 )?;
1871
1872 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1874 let backup_branch_name = format!("{target_branch}_backup_{timestamp}");
1875
1876 warn!(
1877 "⚠️ Force push to '{}' would overwrite {} commits on remote",
1878 target_branch, commits_to_lose
1879 );
1880
1881 if std::env::var("CI").is_ok() || std::env::var("FORCE_PUSH_NO_CONFIRM").is_ok()
1883 {
1884 info!(
1885 "Non-interactive environment detected, proceeding with backup creation"
1886 );
1887 return Ok(Some(ForceBackupInfo {
1888 backup_branch_name,
1889 remote_commit_id: remote.id().to_string(),
1890 commits_that_would_be_lost: commits_to_lose,
1891 }));
1892 }
1893
1894 println!("\n⚠️ FORCE PUSH WARNING ⚠️");
1896 println!("Force push to '{target_branch}' would overwrite {commits_to_lose} commits on remote:");
1897
1898 match self
1900 .get_commits_between(&merge_base_oid.to_string(), &remote.id().to_string())
1901 {
1902 Ok(commits) => {
1903 println!("\nCommits that would be lost:");
1904 for (i, commit) in commits.iter().take(5).enumerate() {
1905 let short_hash = &commit.id().to_string()[..8];
1906 let summary = commit.summary().unwrap_or("<no message>");
1907 println!(" {}. {} - {}", i + 1, short_hash, summary);
1908 }
1909 if commits.len() > 5 {
1910 println!(" ... and {} more commits", commits.len() - 5);
1911 }
1912 }
1913 Err(_) => {
1914 println!(" (Unable to retrieve commit details)");
1915 }
1916 }
1917
1918 println!("\nA backup branch '{backup_branch_name}' will be created before proceeding.");
1919
1920 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
1921 .with_prompt("Do you want to proceed with the force push?")
1922 .default(false)
1923 .interact()
1924 .map_err(|e| {
1925 CascadeError::config(format!("Failed to get user confirmation: {e}"))
1926 })?;
1927
1928 if !confirmed {
1929 return Err(CascadeError::config(
1930 "Force push cancelled by user. Use --force to bypass this check."
1931 .to_string(),
1932 ));
1933 }
1934
1935 return Ok(Some(ForceBackupInfo {
1936 backup_branch_name,
1937 remote_commit_id: remote.id().to_string(),
1938 commits_that_would_be_lost: commits_to_lose,
1939 }));
1940 }
1941 }
1942 }
1943
1944 Ok(None)
1945 }
1946
1947 fn create_backup_branch(&self, original_branch: &str, remote_commit_id: &str) -> Result<()> {
1949 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1950 let backup_branch_name = format!("{original_branch}_backup_{timestamp}");
1951
1952 let commit_oid = Oid::from_str(remote_commit_id).map_err(|e| {
1954 CascadeError::config(format!("Invalid commit ID {remote_commit_id}: {e}"))
1955 })?;
1956
1957 let commit = self.repo.find_commit(commit_oid).map_err(|e| {
1959 CascadeError::config(format!("Failed to find commit {remote_commit_id}: {e}"))
1960 })?;
1961
1962 self.repo
1964 .branch(&backup_branch_name, &commit, false)
1965 .map_err(|e| {
1966 CascadeError::config(format!(
1967 "Failed to create backup branch {backup_branch_name}: {e}"
1968 ))
1969 })?;
1970
1971 info!(
1972 "✅ Created backup branch '{}' pointing to {}",
1973 backup_branch_name,
1974 &remote_commit_id[..8]
1975 );
1976 Ok(())
1977 }
1978
1979 fn check_branch_deletion_safety(
1982 &self,
1983 branch_name: &str,
1984 ) -> Result<Option<BranchDeletionSafety>> {
1985 match self.fetch() {
1987 Ok(_) => {}
1988 Err(e) => {
1989 warn!(
1990 "Could not fetch latest changes for branch deletion safety check: {}",
1991 e
1992 );
1993 }
1994 }
1995
1996 let branch = self
1998 .repo
1999 .find_branch(branch_name, git2::BranchType::Local)
2000 .map_err(|e| {
2001 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
2002 })?;
2003
2004 let _branch_commit = branch.get().peel_to_commit().map_err(|e| {
2005 CascadeError::branch(format!(
2006 "Could not get commit for branch '{branch_name}': {e}"
2007 ))
2008 })?;
2009
2010 let main_branch_name = self.detect_main_branch()?;
2012
2013 let is_merged_to_main = self.is_branch_merged_to_main(branch_name, &main_branch_name)?;
2015
2016 let remote_tracking_branch = self.get_remote_tracking_branch(branch_name);
2018
2019 let mut unpushed_commits = Vec::new();
2020
2021 if let Some(ref remote_branch) = remote_tracking_branch {
2023 match self.get_commits_between(remote_branch, branch_name) {
2024 Ok(commits) => {
2025 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
2026 }
2027 Err(_) => {
2028 if !is_merged_to_main {
2030 if let Ok(commits) =
2031 self.get_commits_between(&main_branch_name, branch_name)
2032 {
2033 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
2034 }
2035 }
2036 }
2037 }
2038 } else if !is_merged_to_main {
2039 if let Ok(commits) = self.get_commits_between(&main_branch_name, branch_name) {
2041 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
2042 }
2043 }
2044
2045 if !unpushed_commits.is_empty() || (!is_merged_to_main && remote_tracking_branch.is_none())
2047 {
2048 Ok(Some(BranchDeletionSafety {
2049 unpushed_commits,
2050 remote_tracking_branch,
2051 is_merged_to_main,
2052 main_branch_name,
2053 }))
2054 } else {
2055 Ok(None)
2056 }
2057 }
2058
2059 fn handle_branch_deletion_confirmation(
2061 &self,
2062 branch_name: &str,
2063 safety_info: &BranchDeletionSafety,
2064 ) -> Result<()> {
2065 if std::env::var("CI").is_ok() || std::env::var("BRANCH_DELETE_NO_CONFIRM").is_ok() {
2067 return Err(CascadeError::branch(
2068 format!(
2069 "Branch '{branch_name}' has {} unpushed commits and cannot be deleted in non-interactive mode. Use --force to override.",
2070 safety_info.unpushed_commits.len()
2071 )
2072 ));
2073 }
2074
2075 println!("\n⚠️ BRANCH DELETION WARNING ⚠️");
2077 println!("Branch '{branch_name}' has potential issues:");
2078
2079 if !safety_info.unpushed_commits.is_empty() {
2080 println!(
2081 "\n🔍 Unpushed commits ({} total):",
2082 safety_info.unpushed_commits.len()
2083 );
2084
2085 for (i, commit_id) in safety_info.unpushed_commits.iter().take(5).enumerate() {
2087 if let Ok(oid) = Oid::from_str(commit_id) {
2088 if let Ok(commit) = self.repo.find_commit(oid) {
2089 let short_hash = &commit_id[..8];
2090 let summary = commit.summary().unwrap_or("<no message>");
2091 println!(" {}. {} - {}", i + 1, short_hash, summary);
2092 }
2093 }
2094 }
2095
2096 if safety_info.unpushed_commits.len() > 5 {
2097 println!(
2098 " ... and {} more commits",
2099 safety_info.unpushed_commits.len() - 5
2100 );
2101 }
2102 }
2103
2104 if !safety_info.is_merged_to_main {
2105 println!("\n📋 Branch status:");
2106 println!(" • Not merged to '{}'", safety_info.main_branch_name);
2107 if let Some(ref remote) = safety_info.remote_tracking_branch {
2108 println!(" • Remote tracking branch: {remote}");
2109 } else {
2110 println!(" • No remote tracking branch");
2111 }
2112 }
2113
2114 println!("\n💡 Safer alternatives:");
2115 if !safety_info.unpushed_commits.is_empty() {
2116 if let Some(ref _remote) = safety_info.remote_tracking_branch {
2117 println!(" • Push commits first: git push origin {branch_name}");
2118 } else {
2119 println!(" • Create and push to remote: git push -u origin {branch_name}");
2120 }
2121 }
2122 if !safety_info.is_merged_to_main {
2123 println!(
2124 " • Merge to {} first: git checkout {} && git merge {branch_name}",
2125 safety_info.main_branch_name, safety_info.main_branch_name
2126 );
2127 }
2128
2129 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
2130 .with_prompt("Do you want to proceed with deleting this branch?")
2131 .default(false)
2132 .interact()
2133 .map_err(|e| CascadeError::branch(format!("Failed to get user confirmation: {e}")))?;
2134
2135 if !confirmed {
2136 return Err(CascadeError::branch(
2137 "Branch deletion cancelled by user. Use --force to bypass this check.".to_string(),
2138 ));
2139 }
2140
2141 Ok(())
2142 }
2143
2144 pub fn detect_main_branch(&self) -> Result<String> {
2146 let main_candidates = ["main", "master", "develop", "trunk"];
2147
2148 for candidate in &main_candidates {
2149 if self
2150 .repo
2151 .find_branch(candidate, git2::BranchType::Local)
2152 .is_ok()
2153 {
2154 return Ok(candidate.to_string());
2155 }
2156 }
2157
2158 if let Ok(head) = self.repo.head() {
2160 if let Some(name) = head.shorthand() {
2161 return Ok(name.to_string());
2162 }
2163 }
2164
2165 Ok("main".to_string())
2167 }
2168
2169 fn is_branch_merged_to_main(&self, branch_name: &str, main_branch: &str) -> Result<bool> {
2171 match self.get_commits_between(main_branch, branch_name) {
2173 Ok(commits) => Ok(commits.is_empty()),
2174 Err(_) => {
2175 Ok(false)
2177 }
2178 }
2179 }
2180
2181 fn get_remote_tracking_branch(&self, branch_name: &str) -> Option<String> {
2183 let remote_candidates = [
2185 format!("origin/{branch_name}"),
2186 format!("remotes/origin/{branch_name}"),
2187 ];
2188
2189 for candidate in &remote_candidates {
2190 if self
2191 .repo
2192 .find_reference(&format!(
2193 "refs/remotes/{}",
2194 candidate.replace("remotes/", "")
2195 ))
2196 .is_ok()
2197 {
2198 return Some(candidate.clone());
2199 }
2200 }
2201
2202 None
2203 }
2204
2205 fn check_checkout_safety(&self, _target: &str) -> Result<Option<CheckoutSafety>> {
2207 let is_dirty = self.is_dirty()?;
2209 if !is_dirty {
2210 return Ok(None);
2212 }
2213
2214 let current_branch = self.get_current_branch().ok();
2216
2217 let modified_files = self.get_modified_files()?;
2219 let staged_files = self.get_staged_files()?;
2220 let untracked_files = self.get_untracked_files()?;
2221
2222 let has_uncommitted_changes = !modified_files.is_empty() || !staged_files.is_empty();
2223
2224 if has_uncommitted_changes || !untracked_files.is_empty() {
2225 return Ok(Some(CheckoutSafety {
2226 has_uncommitted_changes,
2227 modified_files,
2228 staged_files,
2229 untracked_files,
2230 stash_created: None,
2231 current_branch,
2232 }));
2233 }
2234
2235 Ok(None)
2236 }
2237
2238 fn handle_checkout_confirmation(
2240 &self,
2241 target: &str,
2242 safety_info: &CheckoutSafety,
2243 ) -> Result<()> {
2244 let is_ci = std::env::var("CI").is_ok();
2246 let no_confirm = std::env::var("CHECKOUT_NO_CONFIRM").is_ok();
2247 let is_non_interactive = is_ci || no_confirm;
2248
2249 if is_non_interactive {
2250 return Err(CascadeError::branch(
2251 format!(
2252 "Cannot checkout '{target}' with uncommitted changes in non-interactive mode. Commit your changes or use stash first."
2253 )
2254 ));
2255 }
2256
2257 println!("\n⚠️ CHECKOUT WARNING ⚠️");
2259 println!("You have uncommitted changes that could be lost:");
2260
2261 if !safety_info.modified_files.is_empty() {
2262 println!(
2263 "\n📝 Modified files ({}):",
2264 safety_info.modified_files.len()
2265 );
2266 for file in safety_info.modified_files.iter().take(10) {
2267 println!(" - {file}");
2268 }
2269 if safety_info.modified_files.len() > 10 {
2270 println!(" ... and {} more", safety_info.modified_files.len() - 10);
2271 }
2272 }
2273
2274 if !safety_info.staged_files.is_empty() {
2275 println!("\n📁 Staged files ({}):", safety_info.staged_files.len());
2276 for file in safety_info.staged_files.iter().take(10) {
2277 println!(" - {file}");
2278 }
2279 if safety_info.staged_files.len() > 10 {
2280 println!(" ... and {} more", safety_info.staged_files.len() - 10);
2281 }
2282 }
2283
2284 if !safety_info.untracked_files.is_empty() {
2285 println!(
2286 "\n❓ Untracked files ({}):",
2287 safety_info.untracked_files.len()
2288 );
2289 for file in safety_info.untracked_files.iter().take(5) {
2290 println!(" - {file}");
2291 }
2292 if safety_info.untracked_files.len() > 5 {
2293 println!(" ... and {} more", safety_info.untracked_files.len() - 5);
2294 }
2295 }
2296
2297 println!("\n🔄 Options:");
2298 println!("1. Stash changes and checkout (recommended)");
2299 println!("2. Force checkout (WILL LOSE UNCOMMITTED CHANGES)");
2300 println!("3. Cancel checkout");
2301
2302 let selection = Select::with_theme(&ColorfulTheme::default())
2304 .with_prompt("Choose an action")
2305 .items(&[
2306 "Stash changes and checkout (recommended)",
2307 "Force checkout (WILL LOSE UNCOMMITTED CHANGES)",
2308 "Cancel checkout",
2309 ])
2310 .default(0)
2311 .interact()
2312 .map_err(|e| CascadeError::branch(format!("Could not get user selection: {e}")))?;
2313
2314 match selection {
2315 0 => {
2316 let stash_message = format!(
2318 "Auto-stash before checkout to {} at {}",
2319 target,
2320 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
2321 );
2322
2323 match self.create_stash(&stash_message) {
2324 Ok(stash_id) => {
2325 println!("✅ Created stash: {stash_message} ({stash_id})");
2326 println!("💡 You can restore with: git stash pop");
2327 }
2328 Err(e) => {
2329 println!("❌ Failed to create stash: {e}");
2330
2331 use dialoguer::Select;
2333 let stash_failed_options = vec![
2334 "Commit staged changes and proceed",
2335 "Force checkout (WILL LOSE CHANGES)",
2336 "Cancel and handle manually",
2337 ];
2338
2339 let stash_selection = Select::with_theme(&ColorfulTheme::default())
2340 .with_prompt("Stash failed. What would you like to do?")
2341 .items(&stash_failed_options)
2342 .default(0)
2343 .interact()
2344 .map_err(|e| {
2345 CascadeError::branch(format!("Could not get user selection: {e}"))
2346 })?;
2347
2348 match stash_selection {
2349 0 => {
2350 let staged_files = self.get_staged_files()?;
2352 if !staged_files.is_empty() {
2353 println!(
2354 "📝 Committing {} staged files...",
2355 staged_files.len()
2356 );
2357 match self
2358 .commit_staged_changes("WIP: Auto-commit before checkout")
2359 {
2360 Ok(Some(commit_hash)) => {
2361 println!(
2362 "✅ Committed staged changes as {}",
2363 &commit_hash[..8]
2364 );
2365 println!("💡 You can undo with: git reset HEAD~1");
2366 }
2367 Ok(None) => {
2368 println!("ℹ️ No staged changes found to commit");
2369 }
2370 Err(commit_err) => {
2371 println!(
2372 "❌ Failed to commit staged changes: {commit_err}"
2373 );
2374 return Err(CascadeError::branch(
2375 "Could not commit staged changes".to_string(),
2376 ));
2377 }
2378 }
2379 } else {
2380 println!("ℹ️ No staged changes to commit");
2381 }
2382 }
2383 1 => {
2384 println!("⚠️ Proceeding with force checkout - uncommitted changes will be lost!");
2386 }
2387 2 => {
2388 return Err(CascadeError::branch(
2390 "Checkout cancelled. Please handle changes manually and try again.".to_string(),
2391 ));
2392 }
2393 _ => unreachable!(),
2394 }
2395 }
2396 }
2397 }
2398 1 => {
2399 println!("⚠️ Proceeding with force checkout - uncommitted changes will be lost!");
2401 }
2402 2 => {
2403 return Err(CascadeError::branch(
2405 "Checkout cancelled by user".to_string(),
2406 ));
2407 }
2408 _ => unreachable!(),
2409 }
2410
2411 Ok(())
2412 }
2413
2414 fn create_stash(&self, message: &str) -> Result<String> {
2416 tracing::info!("Creating stash: {}", message);
2417
2418 let output = std::process::Command::new("git")
2420 .args(["stash", "push", "-m", message])
2421 .current_dir(&self.path)
2422 .output()
2423 .map_err(|e| {
2424 CascadeError::branch(format!("Failed to execute git stash command: {e}"))
2425 })?;
2426
2427 if output.status.success() {
2428 let stdout = String::from_utf8_lossy(&output.stdout);
2429
2430 let stash_id = if stdout.contains("Saved working directory") {
2432 let stash_list_output = std::process::Command::new("git")
2434 .args(["stash", "list", "-n", "1", "--format=%H"])
2435 .current_dir(&self.path)
2436 .output()
2437 .map_err(|e| CascadeError::branch(format!("Failed to get stash ID: {e}")))?;
2438
2439 if stash_list_output.status.success() {
2440 String::from_utf8_lossy(&stash_list_output.stdout)
2441 .trim()
2442 .to_string()
2443 } else {
2444 "stash@{0}".to_string() }
2446 } else {
2447 "stash@{0}".to_string() };
2449
2450 tracing::info!("✅ Created stash: {} ({})", message, stash_id);
2451 Ok(stash_id)
2452 } else {
2453 let stderr = String::from_utf8_lossy(&output.stderr);
2454 let stdout = String::from_utf8_lossy(&output.stdout);
2455
2456 if stderr.contains("No local changes to save")
2458 || stdout.contains("No local changes to save")
2459 {
2460 return Err(CascadeError::branch("No local changes to save".to_string()));
2461 }
2462
2463 Err(CascadeError::branch(format!(
2464 "Failed to create stash: {}\nStderr: {}\nStdout: {}",
2465 output.status, stderr, stdout
2466 )))
2467 }
2468 }
2469
2470 fn get_modified_files(&self) -> Result<Vec<String>> {
2472 let mut opts = git2::StatusOptions::new();
2473 opts.include_untracked(false).include_ignored(false);
2474
2475 let statuses = self
2476 .repo
2477 .statuses(Some(&mut opts))
2478 .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
2479
2480 let mut modified_files = Vec::new();
2481 for status in statuses.iter() {
2482 let flags = status.status();
2483 if flags.contains(git2::Status::WT_MODIFIED) || flags.contains(git2::Status::WT_DELETED)
2484 {
2485 if let Some(path) = status.path() {
2486 modified_files.push(path.to_string());
2487 }
2488 }
2489 }
2490
2491 Ok(modified_files)
2492 }
2493
2494 pub fn get_staged_files(&self) -> Result<Vec<String>> {
2496 let mut opts = git2::StatusOptions::new();
2497 opts.include_untracked(false).include_ignored(false);
2498
2499 let statuses = self
2500 .repo
2501 .statuses(Some(&mut opts))
2502 .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
2503
2504 let mut staged_files = Vec::new();
2505 for status in statuses.iter() {
2506 let flags = status.status();
2507 if flags.contains(git2::Status::INDEX_MODIFIED)
2508 || flags.contains(git2::Status::INDEX_NEW)
2509 || flags.contains(git2::Status::INDEX_DELETED)
2510 {
2511 if let Some(path) = status.path() {
2512 staged_files.push(path.to_string());
2513 }
2514 }
2515 }
2516
2517 Ok(staged_files)
2518 }
2519
2520 fn count_commits_between(&self, from: &str, to: &str) -> Result<usize> {
2522 let commits = self.get_commits_between(from, to)?;
2523 Ok(commits.len())
2524 }
2525
2526 pub fn resolve_reference(&self, reference: &str) -> Result<git2::Commit<'_>> {
2528 if let Ok(oid) = Oid::from_str(reference) {
2530 if let Ok(commit) = self.repo.find_commit(oid) {
2531 return Ok(commit);
2532 }
2533 }
2534
2535 let obj = self.repo.revparse_single(reference).map_err(|e| {
2537 CascadeError::branch(format!("Could not resolve reference '{reference}': {e}"))
2538 })?;
2539
2540 obj.peel_to_commit().map_err(|e| {
2541 CascadeError::branch(format!(
2542 "Reference '{reference}' does not point to a commit: {e}"
2543 ))
2544 })
2545 }
2546
2547 pub fn reset_soft(&self, target_ref: &str) -> Result<()> {
2549 let target_commit = self.resolve_reference(target_ref)?;
2550
2551 self.repo
2552 .reset(target_commit.as_object(), git2::ResetType::Soft, None)
2553 .map_err(CascadeError::Git)?;
2554
2555 Ok(())
2556 }
2557
2558 pub fn reset_to_head(&self) -> Result<()> {
2561 tracing::debug!("Resetting working directory and index to HEAD");
2562
2563 let head = self.repo.head().map_err(CascadeError::Git)?;
2564 let head_commit = head.peel_to_commit().map_err(CascadeError::Git)?;
2565
2566 let mut checkout_builder = git2::build::CheckoutBuilder::new();
2568 checkout_builder.force(); checkout_builder.remove_untracked(false); self.repo
2572 .reset(
2573 head_commit.as_object(),
2574 git2::ResetType::Hard,
2575 Some(&mut checkout_builder),
2576 )
2577 .map_err(CascadeError::Git)?;
2578
2579 tracing::debug!("Successfully reset working directory to HEAD");
2580 Ok(())
2581 }
2582
2583 pub fn find_branch_containing_commit(&self, commit_hash: &str) -> Result<String> {
2585 let oid = Oid::from_str(commit_hash).map_err(|e| {
2586 CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
2587 })?;
2588
2589 let branches = self
2591 .repo
2592 .branches(Some(git2::BranchType::Local))
2593 .map_err(CascadeError::Git)?;
2594
2595 for branch_result in branches {
2596 let (branch, _) = branch_result.map_err(CascadeError::Git)?;
2597
2598 if let Some(branch_name) = branch.name().map_err(CascadeError::Git)? {
2599 if let Ok(branch_head) = branch.get().peel_to_commit() {
2601 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
2603 revwalk.push(branch_head.id()).map_err(CascadeError::Git)?;
2604
2605 for commit_oid in revwalk {
2606 let commit_oid = commit_oid.map_err(CascadeError::Git)?;
2607 if commit_oid == oid {
2608 return Ok(branch_name.to_string());
2609 }
2610 }
2611 }
2612 }
2613 }
2614
2615 Err(CascadeError::branch(format!(
2617 "Commit {commit_hash} not found in any local branch"
2618 )))
2619 }
2620
2621 pub async fn fetch_async(&self) -> Result<()> {
2625 let repo_path = self.path.clone();
2626 crate::utils::async_ops::run_git_operation(move || {
2627 let repo = GitRepository::open(&repo_path)?;
2628 repo.fetch()
2629 })
2630 .await
2631 }
2632
2633 pub async fn pull_async(&self, branch: &str) -> Result<()> {
2635 let repo_path = self.path.clone();
2636 let branch_name = branch.to_string();
2637 crate::utils::async_ops::run_git_operation(move || {
2638 let repo = GitRepository::open(&repo_path)?;
2639 repo.pull(&branch_name)
2640 })
2641 .await
2642 }
2643
2644 pub async fn push_branch_async(&self, branch_name: &str) -> Result<()> {
2646 let repo_path = self.path.clone();
2647 let branch = branch_name.to_string();
2648 crate::utils::async_ops::run_git_operation(move || {
2649 let repo = GitRepository::open(&repo_path)?;
2650 repo.push(&branch)
2651 })
2652 .await
2653 }
2654
2655 pub async fn cherry_pick_commit_async(&self, commit_hash: &str) -> Result<String> {
2657 let repo_path = self.path.clone();
2658 let hash = commit_hash.to_string();
2659 crate::utils::async_ops::run_git_operation(move || {
2660 let repo = GitRepository::open(&repo_path)?;
2661 repo.cherry_pick(&hash)
2662 })
2663 .await
2664 }
2665
2666 pub async fn get_commit_hashes_between_async(
2668 &self,
2669 from: &str,
2670 to: &str,
2671 ) -> Result<Vec<String>> {
2672 let repo_path = self.path.clone();
2673 let from_str = from.to_string();
2674 let to_str = to.to_string();
2675 crate::utils::async_ops::run_git_operation(move || {
2676 let repo = GitRepository::open(&repo_path)?;
2677 let commits = repo.get_commits_between(&from_str, &to_str)?;
2678 Ok(commits.into_iter().map(|c| c.id().to_string()).collect())
2679 })
2680 .await
2681 }
2682
2683 pub fn reset_branch_to_commit(&self, branch_name: &str, commit_hash: &str) -> Result<()> {
2685 info!(
2686 "Resetting branch '{}' to commit {}",
2687 branch_name,
2688 &commit_hash[..8]
2689 );
2690
2691 let target_oid = git2::Oid::from_str(commit_hash).map_err(|e| {
2693 CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
2694 })?;
2695
2696 let _target_commit = self.repo.find_commit(target_oid).map_err(|e| {
2697 CascadeError::branch(format!("Could not find commit '{commit_hash}': {e}"))
2698 })?;
2699
2700 let _branch = self
2702 .repo
2703 .find_branch(branch_name, git2::BranchType::Local)
2704 .map_err(|e| {
2705 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
2706 })?;
2707
2708 let branch_ref_name = format!("refs/heads/{branch_name}");
2710 self.repo
2711 .reference(
2712 &branch_ref_name,
2713 target_oid,
2714 true,
2715 &format!("Reset {branch_name} to {commit_hash}"),
2716 )
2717 .map_err(|e| {
2718 CascadeError::branch(format!(
2719 "Could not reset branch '{branch_name}' to commit '{commit_hash}': {e}"
2720 ))
2721 })?;
2722
2723 tracing::info!(
2724 "Successfully reset branch '{}' to commit {}",
2725 branch_name,
2726 &commit_hash[..8]
2727 );
2728 Ok(())
2729 }
2730
2731 pub fn detect_parent_branch(&self) -> Result<Option<String>> {
2733 let current_branch = self.get_current_branch()?;
2734
2735 if let Ok(Some(upstream)) = self.get_upstream_branch(¤t_branch) {
2737 if let Some(branch_name) = upstream.split('/').nth(1) {
2739 if self.branch_exists(branch_name) {
2740 tracing::debug!(
2741 "Detected parent branch '{}' from upstream tracking",
2742 branch_name
2743 );
2744 return Ok(Some(branch_name.to_string()));
2745 }
2746 }
2747 }
2748
2749 if let Ok(default_branch) = self.detect_main_branch() {
2751 if current_branch != default_branch {
2753 tracing::debug!(
2754 "Detected parent branch '{}' as repository default",
2755 default_branch
2756 );
2757 return Ok(Some(default_branch));
2758 }
2759 }
2760
2761 if let Ok(branches) = self.list_branches() {
2764 let current_commit = self.get_head_commit()?;
2765 let current_commit_hash = current_commit.id().to_string();
2766 let current_oid = current_commit.id();
2767
2768 let mut best_candidate = None;
2769 let mut best_distance = usize::MAX;
2770
2771 for branch in branches {
2772 if branch == current_branch
2774 || branch.contains("-v")
2775 || branch.ends_with("-v2")
2776 || branch.ends_with("-v3")
2777 {
2778 continue;
2779 }
2780
2781 if let Ok(base_commit_hash) = self.get_branch_commit_hash(&branch) {
2782 if let Ok(base_oid) = git2::Oid::from_str(&base_commit_hash) {
2783 if let Ok(merge_base_oid) = self.repo.merge_base(current_oid, base_oid) {
2785 if let Ok(distance) = self.count_commits_between(
2787 &merge_base_oid.to_string(),
2788 ¤t_commit_hash,
2789 ) {
2790 let is_likely_base = self.is_likely_base_branch(&branch);
2793 let adjusted_distance = if is_likely_base {
2794 distance
2795 } else {
2796 distance + 1000
2797 };
2798
2799 if adjusted_distance < best_distance {
2800 best_distance = adjusted_distance;
2801 best_candidate = Some(branch.clone());
2802 }
2803 }
2804 }
2805 }
2806 }
2807 }
2808
2809 if let Some(ref candidate) = best_candidate {
2810 tracing::debug!(
2811 "Detected parent branch '{}' with distance {}",
2812 candidate,
2813 best_distance
2814 );
2815 }
2816
2817 return Ok(best_candidate);
2818 }
2819
2820 tracing::debug!("Could not detect parent branch for '{}'", current_branch);
2821 Ok(None)
2822 }
2823
2824 fn is_likely_base_branch(&self, branch_name: &str) -> bool {
2826 let base_patterns = [
2827 "main",
2828 "master",
2829 "develop",
2830 "dev",
2831 "development",
2832 "staging",
2833 "stage",
2834 "release",
2835 "production",
2836 "prod",
2837 ];
2838
2839 base_patterns.contains(&branch_name)
2840 }
2841}
2842
2843#[cfg(test)]
2844mod tests {
2845 use super::*;
2846 use std::process::Command;
2847 use tempfile::TempDir;
2848
2849 fn create_test_repo() -> (TempDir, PathBuf) {
2850 let temp_dir = TempDir::new().unwrap();
2851 let repo_path = temp_dir.path().to_path_buf();
2852
2853 Command::new("git")
2855 .args(["init"])
2856 .current_dir(&repo_path)
2857 .output()
2858 .unwrap();
2859 Command::new("git")
2860 .args(["config", "user.name", "Test"])
2861 .current_dir(&repo_path)
2862 .output()
2863 .unwrap();
2864 Command::new("git")
2865 .args(["config", "user.email", "test@test.com"])
2866 .current_dir(&repo_path)
2867 .output()
2868 .unwrap();
2869
2870 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
2872 Command::new("git")
2873 .args(["add", "."])
2874 .current_dir(&repo_path)
2875 .output()
2876 .unwrap();
2877 Command::new("git")
2878 .args(["commit", "-m", "Initial commit"])
2879 .current_dir(&repo_path)
2880 .output()
2881 .unwrap();
2882
2883 (temp_dir, repo_path)
2884 }
2885
2886 fn create_commit(repo_path: &PathBuf, message: &str, filename: &str) {
2887 let file_path = repo_path.join(filename);
2888 std::fs::write(&file_path, format!("Content for {filename}\n")).unwrap();
2889
2890 Command::new("git")
2891 .args(["add", filename])
2892 .current_dir(repo_path)
2893 .output()
2894 .unwrap();
2895 Command::new("git")
2896 .args(["commit", "-m", message])
2897 .current_dir(repo_path)
2898 .output()
2899 .unwrap();
2900 }
2901
2902 #[test]
2903 fn test_repository_info() {
2904 let (_temp_dir, repo_path) = create_test_repo();
2905 let repo = GitRepository::open(&repo_path).unwrap();
2906
2907 let info = repo.get_info().unwrap();
2908 assert!(!info.is_dirty); assert!(
2910 info.head_branch == Some("master".to_string())
2911 || info.head_branch == Some("main".to_string()),
2912 "Expected default branch to be 'master' or 'main', got {:?}",
2913 info.head_branch
2914 );
2915 assert!(info.head_commit.is_some()); assert!(info.untracked_files.is_empty()); }
2918
2919 #[test]
2920 fn test_force_push_branch_basic() {
2921 let (_temp_dir, repo_path) = create_test_repo();
2922 let repo = GitRepository::open(&repo_path).unwrap();
2923
2924 let default_branch = repo.get_current_branch().unwrap();
2926
2927 create_commit(&repo_path, "Feature commit 1", "feature1.rs");
2929 Command::new("git")
2930 .args(["checkout", "-b", "source-branch"])
2931 .current_dir(&repo_path)
2932 .output()
2933 .unwrap();
2934 create_commit(&repo_path, "Feature commit 2", "feature2.rs");
2935
2936 Command::new("git")
2938 .args(["checkout", &default_branch])
2939 .current_dir(&repo_path)
2940 .output()
2941 .unwrap();
2942 Command::new("git")
2943 .args(["checkout", "-b", "target-branch"])
2944 .current_dir(&repo_path)
2945 .output()
2946 .unwrap();
2947 create_commit(&repo_path, "Target commit", "target.rs");
2948
2949 let result = repo.force_push_branch("target-branch", "source-branch");
2951
2952 assert!(result.is_ok() || result.is_err()); }
2956
2957 #[test]
2958 fn test_force_push_branch_nonexistent_branches() {
2959 let (_temp_dir, repo_path) = create_test_repo();
2960 let repo = GitRepository::open(&repo_path).unwrap();
2961
2962 let default_branch = repo.get_current_branch().unwrap();
2964
2965 let result = repo.force_push_branch("target", "nonexistent-source");
2967 assert!(result.is_err());
2968
2969 let result = repo.force_push_branch("nonexistent-target", &default_branch);
2971 assert!(result.is_err());
2972 }
2973
2974 #[test]
2975 fn test_force_push_workflow_simulation() {
2976 let (_temp_dir, repo_path) = create_test_repo();
2977 let repo = GitRepository::open(&repo_path).unwrap();
2978
2979 Command::new("git")
2982 .args(["checkout", "-b", "feature-auth"])
2983 .current_dir(&repo_path)
2984 .output()
2985 .unwrap();
2986 create_commit(&repo_path, "Add authentication", "auth.rs");
2987
2988 Command::new("git")
2990 .args(["checkout", "-b", "feature-auth-v2"])
2991 .current_dir(&repo_path)
2992 .output()
2993 .unwrap();
2994 create_commit(&repo_path, "Fix auth validation", "auth.rs");
2995
2996 let result = repo.force_push_branch("feature-auth", "feature-auth-v2");
2998
2999 match result {
3001 Ok(_) => {
3002 Command::new("git")
3004 .args(["checkout", "feature-auth"])
3005 .current_dir(&repo_path)
3006 .output()
3007 .unwrap();
3008 let log_output = Command::new("git")
3009 .args(["log", "--oneline", "-2"])
3010 .current_dir(&repo_path)
3011 .output()
3012 .unwrap();
3013 let log_str = String::from_utf8_lossy(&log_output.stdout);
3014 assert!(
3015 log_str.contains("Fix auth validation")
3016 || log_str.contains("Add authentication")
3017 );
3018 }
3019 Err(_) => {
3020 }
3023 }
3024 }
3025
3026 #[test]
3027 fn test_branch_operations() {
3028 let (_temp_dir, repo_path) = create_test_repo();
3029 let repo = GitRepository::open(&repo_path).unwrap();
3030
3031 let current = repo.get_current_branch().unwrap();
3033 assert!(
3034 current == "master" || current == "main",
3035 "Expected default branch to be 'master' or 'main', got '{current}'"
3036 );
3037
3038 Command::new("git")
3040 .args(["checkout", "-b", "test-branch"])
3041 .current_dir(&repo_path)
3042 .output()
3043 .unwrap();
3044 let current = repo.get_current_branch().unwrap();
3045 assert_eq!(current, "test-branch");
3046 }
3047
3048 #[test]
3049 fn test_commit_operations() {
3050 let (_temp_dir, repo_path) = create_test_repo();
3051 let repo = GitRepository::open(&repo_path).unwrap();
3052
3053 let head = repo.get_head_commit().unwrap();
3055 assert_eq!(head.message().unwrap().trim(), "Initial commit");
3056
3057 let hash = head.id().to_string();
3059 let same_commit = repo.get_commit(&hash).unwrap();
3060 assert_eq!(head.id(), same_commit.id());
3061 }
3062
3063 #[test]
3064 fn test_checkout_safety_clean_repo() {
3065 let (_temp_dir, repo_path) = create_test_repo();
3066 let repo = GitRepository::open(&repo_path).unwrap();
3067
3068 create_commit(&repo_path, "Second commit", "test.txt");
3070 Command::new("git")
3071 .args(["checkout", "-b", "test-branch"])
3072 .current_dir(&repo_path)
3073 .output()
3074 .unwrap();
3075
3076 let safety_result = repo.check_checkout_safety("main");
3078 assert!(safety_result.is_ok());
3079 assert!(safety_result.unwrap().is_none()); }
3081
3082 #[test]
3083 fn test_checkout_safety_with_modified_files() {
3084 let (_temp_dir, repo_path) = create_test_repo();
3085 let repo = GitRepository::open(&repo_path).unwrap();
3086
3087 Command::new("git")
3089 .args(["checkout", "-b", "test-branch"])
3090 .current_dir(&repo_path)
3091 .output()
3092 .unwrap();
3093
3094 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
3096
3097 let safety_result = repo.check_checkout_safety("main");
3099 assert!(safety_result.is_ok());
3100 let safety_info = safety_result.unwrap();
3101 assert!(safety_info.is_some());
3102
3103 let info = safety_info.unwrap();
3104 assert!(!info.modified_files.is_empty());
3105 assert!(info.modified_files.contains(&"README.md".to_string()));
3106 }
3107
3108 #[test]
3109 fn test_unsafe_checkout_methods() {
3110 let (_temp_dir, repo_path) = create_test_repo();
3111 let repo = GitRepository::open(&repo_path).unwrap();
3112
3113 create_commit(&repo_path, "Second commit", "test.txt");
3115 Command::new("git")
3116 .args(["checkout", "-b", "test-branch"])
3117 .current_dir(&repo_path)
3118 .output()
3119 .unwrap();
3120
3121 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
3123
3124 let _result = repo.checkout_branch_unsafe("main");
3126 let head_commit = repo.get_head_commit().unwrap();
3131 let commit_hash = head_commit.id().to_string();
3132 let _result = repo.checkout_commit_unsafe(&commit_hash);
3133 }
3135
3136 #[test]
3137 fn test_get_modified_files() {
3138 let (_temp_dir, repo_path) = create_test_repo();
3139 let repo = GitRepository::open(&repo_path).unwrap();
3140
3141 let modified = repo.get_modified_files().unwrap();
3143 assert!(modified.is_empty());
3144
3145 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
3147
3148 let modified = repo.get_modified_files().unwrap();
3150 assert_eq!(modified.len(), 1);
3151 assert!(modified.contains(&"README.md".to_string()));
3152 }
3153
3154 #[test]
3155 fn test_get_staged_files() {
3156 let (_temp_dir, repo_path) = create_test_repo();
3157 let repo = GitRepository::open(&repo_path).unwrap();
3158
3159 let staged = repo.get_staged_files().unwrap();
3161 assert!(staged.is_empty());
3162
3163 std::fs::write(repo_path.join("staged.txt"), "Staged content").unwrap();
3165 Command::new("git")
3166 .args(["add", "staged.txt"])
3167 .current_dir(&repo_path)
3168 .output()
3169 .unwrap();
3170
3171 let staged = repo.get_staged_files().unwrap();
3173 assert_eq!(staged.len(), 1);
3174 assert!(staged.contains(&"staged.txt".to_string()));
3175 }
3176
3177 #[test]
3178 fn test_create_stash_fallback() {
3179 let (_temp_dir, repo_path) = create_test_repo();
3180 let repo = GitRepository::open(&repo_path).unwrap();
3181
3182 let result = repo.create_stash("test stash");
3184
3185 match result {
3187 Ok(stash_id) => {
3188 assert!(!stash_id.is_empty());
3190 assert!(stash_id.contains("stash") || stash_id.len() >= 7); }
3192 Err(error) => {
3193 let error_msg = error.to_string();
3195 assert!(
3196 error_msg.contains("No local changes to save")
3197 || error_msg.contains("git stash push")
3198 );
3199 }
3200 }
3201 }
3202
3203 #[test]
3204 fn test_delete_branch_unsafe() {
3205 let (_temp_dir, repo_path) = create_test_repo();
3206 let repo = GitRepository::open(&repo_path).unwrap();
3207
3208 create_commit(&repo_path, "Second commit", "test.txt");
3210 Command::new("git")
3211 .args(["checkout", "-b", "test-branch"])
3212 .current_dir(&repo_path)
3213 .output()
3214 .unwrap();
3215
3216 create_commit(&repo_path, "Branch-specific commit", "branch.txt");
3218
3219 Command::new("git")
3221 .args(["checkout", "main"])
3222 .current_dir(&repo_path)
3223 .output()
3224 .unwrap();
3225
3226 let result = repo.delete_branch_unsafe("test-branch");
3229 let _ = result; }
3233
3234 #[test]
3235 fn test_force_push_unsafe() {
3236 let (_temp_dir, repo_path) = create_test_repo();
3237 let repo = GitRepository::open(&repo_path).unwrap();
3238
3239 create_commit(&repo_path, "Second commit", "test.txt");
3241 Command::new("git")
3242 .args(["checkout", "-b", "test-branch"])
3243 .current_dir(&repo_path)
3244 .output()
3245 .unwrap();
3246
3247 let _result = repo.force_push_branch_unsafe("test-branch", "test-branch");
3250 }
3252
3253 #[test]
3254 fn test_cherry_pick_basic() {
3255 let (_temp_dir, repo_path) = create_test_repo();
3256 let repo = GitRepository::open(&repo_path).unwrap();
3257
3258 repo.create_branch("source", None).unwrap();
3260 repo.checkout_branch("source").unwrap();
3261
3262 std::fs::write(repo_path.join("cherry.txt"), "Cherry content").unwrap();
3263 Command::new("git")
3264 .args(["add", "."])
3265 .current_dir(&repo_path)
3266 .output()
3267 .unwrap();
3268
3269 Command::new("git")
3270 .args(["commit", "-m", "Cherry commit"])
3271 .current_dir(&repo_path)
3272 .output()
3273 .unwrap();
3274
3275 let cherry_commit = repo.get_head_commit_hash().unwrap();
3276
3277 Command::new("git")
3280 .args(["checkout", "-"])
3281 .current_dir(&repo_path)
3282 .output()
3283 .unwrap();
3284
3285 repo.create_branch("target", None).unwrap();
3286 repo.checkout_branch("target").unwrap();
3287
3288 let new_commit = repo.cherry_pick(&cherry_commit).unwrap();
3290
3291 assert_ne!(new_commit, cherry_commit, "Should create new commit hash");
3293
3294 assert!(
3296 repo_path.join("cherry.txt").exists(),
3297 "Cherry-picked file should exist"
3298 );
3299
3300 repo.checkout_branch("source").unwrap();
3302 let source_head = repo.get_head_commit_hash().unwrap();
3303 assert_eq!(
3304 source_head, cherry_commit,
3305 "Source branch should be unchanged"
3306 );
3307 }
3308
3309 #[test]
3310 fn test_cherry_pick_preserves_commit_message() {
3311 let (_temp_dir, repo_path) = create_test_repo();
3312 let repo = GitRepository::open(&repo_path).unwrap();
3313
3314 repo.create_branch("msg-test", None).unwrap();
3316 repo.checkout_branch("msg-test").unwrap();
3317
3318 std::fs::write(repo_path.join("msg.txt"), "Content").unwrap();
3319 Command::new("git")
3320 .args(["add", "."])
3321 .current_dir(&repo_path)
3322 .output()
3323 .unwrap();
3324
3325 let commit_msg = "Test: Special commit message\n\nWith body";
3326 Command::new("git")
3327 .args(["commit", "-m", commit_msg])
3328 .current_dir(&repo_path)
3329 .output()
3330 .unwrap();
3331
3332 let original_commit = repo.get_head_commit_hash().unwrap();
3333
3334 Command::new("git")
3336 .args(["checkout", "-"])
3337 .current_dir(&repo_path)
3338 .output()
3339 .unwrap();
3340 let new_commit = repo.cherry_pick(&original_commit).unwrap();
3341
3342 let output = Command::new("git")
3344 .args(["log", "-1", "--format=%B", &new_commit])
3345 .current_dir(&repo_path)
3346 .output()
3347 .unwrap();
3348
3349 let new_msg = String::from_utf8_lossy(&output.stdout);
3350 assert!(
3351 new_msg.contains("Special commit message"),
3352 "Should preserve commit message"
3353 );
3354 }
3355
3356 #[test]
3357 fn test_cherry_pick_handles_conflicts() {
3358 let (_temp_dir, repo_path) = create_test_repo();
3359 let repo = GitRepository::open(&repo_path).unwrap();
3360
3361 std::fs::write(repo_path.join("conflict.txt"), "Original").unwrap();
3363 Command::new("git")
3364 .args(["add", "."])
3365 .current_dir(&repo_path)
3366 .output()
3367 .unwrap();
3368
3369 Command::new("git")
3370 .args(["commit", "-m", "Add conflict file"])
3371 .current_dir(&repo_path)
3372 .output()
3373 .unwrap();
3374
3375 repo.create_branch("conflict-branch", None).unwrap();
3377 repo.checkout_branch("conflict-branch").unwrap();
3378
3379 std::fs::write(repo_path.join("conflict.txt"), "Modified").unwrap();
3380 Command::new("git")
3381 .args(["add", "."])
3382 .current_dir(&repo_path)
3383 .output()
3384 .unwrap();
3385
3386 Command::new("git")
3387 .args(["commit", "-m", "Modify conflict file"])
3388 .current_dir(&repo_path)
3389 .output()
3390 .unwrap();
3391
3392 let conflict_commit = repo.get_head_commit_hash().unwrap();
3393
3394 Command::new("git")
3397 .args(["checkout", "-"])
3398 .current_dir(&repo_path)
3399 .output()
3400 .unwrap();
3401 std::fs::write(repo_path.join("conflict.txt"), "Different").unwrap();
3402 Command::new("git")
3403 .args(["add", "."])
3404 .current_dir(&repo_path)
3405 .output()
3406 .unwrap();
3407
3408 Command::new("git")
3409 .args(["commit", "-m", "Different change"])
3410 .current_dir(&repo_path)
3411 .output()
3412 .unwrap();
3413
3414 let result = repo.cherry_pick(&conflict_commit);
3416 assert!(result.is_err(), "Cherry-pick with conflict should fail");
3417 }
3418
3419 #[test]
3420 fn test_reset_to_head_clears_staged_files() {
3421 let (_temp_dir, repo_path) = create_test_repo();
3422 let repo = GitRepository::open(&repo_path).unwrap();
3423
3424 std::fs::write(repo_path.join("staged1.txt"), "Content 1").unwrap();
3426 std::fs::write(repo_path.join("staged2.txt"), "Content 2").unwrap();
3427
3428 Command::new("git")
3429 .args(["add", "staged1.txt", "staged2.txt"])
3430 .current_dir(&repo_path)
3431 .output()
3432 .unwrap();
3433
3434 let staged_before = repo.get_staged_files().unwrap();
3436 assert_eq!(staged_before.len(), 2, "Should have 2 staged files");
3437
3438 repo.reset_to_head().unwrap();
3440
3441 let staged_after = repo.get_staged_files().unwrap();
3443 assert_eq!(
3444 staged_after.len(),
3445 0,
3446 "Should have no staged files after reset"
3447 );
3448 }
3449
3450 #[test]
3451 fn test_reset_to_head_clears_modified_files() {
3452 let (_temp_dir, repo_path) = create_test_repo();
3453 let repo = GitRepository::open(&repo_path).unwrap();
3454
3455 std::fs::write(repo_path.join("README.md"), "# Modified content").unwrap();
3457
3458 Command::new("git")
3460 .args(["add", "README.md"])
3461 .current_dir(&repo_path)
3462 .output()
3463 .unwrap();
3464
3465 assert!(repo.is_dirty().unwrap(), "Repo should be dirty");
3467
3468 repo.reset_to_head().unwrap();
3470
3471 assert!(
3473 !repo.is_dirty().unwrap(),
3474 "Repo should be clean after reset"
3475 );
3476
3477 let content = std::fs::read_to_string(repo_path.join("README.md")).unwrap();
3479 assert_eq!(
3480 content, "# Test",
3481 "File should be restored to original content"
3482 );
3483 }
3484
3485 #[test]
3486 fn test_reset_to_head_preserves_untracked_files() {
3487 let (_temp_dir, repo_path) = create_test_repo();
3488 let repo = GitRepository::open(&repo_path).unwrap();
3489
3490 std::fs::write(repo_path.join("untracked.txt"), "Untracked content").unwrap();
3492
3493 std::fs::write(repo_path.join("staged.txt"), "Staged content").unwrap();
3495 Command::new("git")
3496 .args(["add", "staged.txt"])
3497 .current_dir(&repo_path)
3498 .output()
3499 .unwrap();
3500
3501 repo.reset_to_head().unwrap();
3503
3504 assert!(
3506 repo_path.join("untracked.txt").exists(),
3507 "Untracked file should be preserved"
3508 );
3509
3510 assert!(
3512 !repo_path.join("staged.txt").exists(),
3513 "Staged but uncommitted file should be removed"
3514 );
3515 }
3516
3517 #[test]
3518 fn test_cherry_pick_does_not_modify_source() {
3519 let (_temp_dir, repo_path) = create_test_repo();
3520 let repo = GitRepository::open(&repo_path).unwrap();
3521
3522 repo.create_branch("feature", None).unwrap();
3524 repo.checkout_branch("feature").unwrap();
3525
3526 for i in 1..=3 {
3528 std::fs::write(
3529 repo_path.join(format!("file{i}.txt")),
3530 format!("Content {i}"),
3531 )
3532 .unwrap();
3533 Command::new("git")
3534 .args(["add", "."])
3535 .current_dir(&repo_path)
3536 .output()
3537 .unwrap();
3538
3539 Command::new("git")
3540 .args(["commit", "-m", &format!("Commit {i}")])
3541 .current_dir(&repo_path)
3542 .output()
3543 .unwrap();
3544 }
3545
3546 let source_commits = Command::new("git")
3548 .args(["log", "--format=%H", "feature"])
3549 .current_dir(&repo_path)
3550 .output()
3551 .unwrap();
3552 let source_state = String::from_utf8_lossy(&source_commits.stdout).to_string();
3553
3554 let commits: Vec<&str> = source_state.lines().collect();
3556 let middle_commit = commits[1];
3557
3558 Command::new("git")
3560 .args(["checkout", "-"])
3561 .current_dir(&repo_path)
3562 .output()
3563 .unwrap();
3564 repo.create_branch("target", None).unwrap();
3565 repo.checkout_branch("target").unwrap();
3566
3567 repo.cherry_pick(middle_commit).unwrap();
3568
3569 let after_commits = Command::new("git")
3571 .args(["log", "--format=%H", "feature"])
3572 .current_dir(&repo_path)
3573 .output()
3574 .unwrap();
3575 let after_state = String::from_utf8_lossy(&after_commits.stdout).to_string();
3576
3577 assert_eq!(
3578 source_state, after_state,
3579 "Source branch should be completely unchanged after cherry-pick"
3580 );
3581 }
3582
3583 #[test]
3584 fn test_detect_parent_branch() {
3585 let (_temp_dir, repo_path) = create_test_repo();
3586 let repo = GitRepository::open(&repo_path).unwrap();
3587
3588 repo.create_branch("dev123", None).unwrap();
3590 repo.checkout_branch("dev123").unwrap();
3591 create_commit(&repo_path, "Base commit on dev123", "base.txt");
3592
3593 repo.create_branch("feature-branch", None).unwrap();
3595 repo.checkout_branch("feature-branch").unwrap();
3596 create_commit(&repo_path, "Feature commit", "feature.txt");
3597
3598 let detected_parent = repo.detect_parent_branch().unwrap();
3600
3601 assert!(detected_parent.is_some(), "Should detect a parent branch");
3604
3605 let parent = detected_parent.unwrap();
3608 assert!(
3609 parent == "dev123" || parent == "main" || parent == "master",
3610 "Parent should be dev123, main, or master, got: {parent}"
3611 );
3612 }
3613}