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
58pub struct GitRepository {
64 repo: Repository,
65 path: PathBuf,
66 ssl_config: Option<GitSslConfig>,
67 bitbucket_credentials: Option<BitbucketCredentials>,
68}
69
70#[derive(Debug, Clone)]
71struct BitbucketCredentials {
72 username: Option<String>,
73 token: Option<String>,
74}
75
76impl GitRepository {
77 pub fn open(path: &Path) -> Result<Self> {
80 let repo = Repository::discover(path)
81 .map_err(|e| CascadeError::config(format!("Not a git repository: {e}")))?;
82
83 let workdir = repo
84 .workdir()
85 .ok_or_else(|| CascadeError::config("Repository has no working directory"))?
86 .to_path_buf();
87
88 let ssl_config = Self::load_ssl_config_from_cascade(&workdir);
90 let bitbucket_credentials = Self::load_bitbucket_credentials_from_cascade(&workdir);
91
92 Ok(Self {
93 repo,
94 path: workdir,
95 ssl_config,
96 bitbucket_credentials,
97 })
98 }
99
100 fn load_ssl_config_from_cascade(repo_path: &Path) -> Option<GitSslConfig> {
102 let config_dir = crate::config::get_repo_config_dir(repo_path).ok()?;
104 let config_path = config_dir.join("config.json");
105 let settings = crate::config::Settings::load_from_file(&config_path).ok()?;
106
107 if settings.bitbucket.accept_invalid_certs.is_some()
109 || settings.bitbucket.ca_bundle_path.is_some()
110 {
111 Some(GitSslConfig {
112 accept_invalid_certs: settings.bitbucket.accept_invalid_certs.unwrap_or(false),
113 ca_bundle_path: settings.bitbucket.ca_bundle_path,
114 })
115 } else {
116 None
117 }
118 }
119
120 fn load_bitbucket_credentials_from_cascade(repo_path: &Path) -> Option<BitbucketCredentials> {
122 let config_dir = crate::config::get_repo_config_dir(repo_path).ok()?;
124 let config_path = config_dir.join("config.json");
125 let settings = crate::config::Settings::load_from_file(&config_path).ok()?;
126
127 if settings.bitbucket.username.is_some() || settings.bitbucket.token.is_some() {
129 Some(BitbucketCredentials {
130 username: settings.bitbucket.username.clone(),
131 token: settings.bitbucket.token.clone(),
132 })
133 } else {
134 None
135 }
136 }
137
138 pub fn get_info(&self) -> Result<RepositoryInfo> {
140 let head_branch = self.get_current_branch().ok();
141 let head_commit = self.get_head_commit_hash().ok();
142 let is_dirty = self.is_dirty()?;
143 let untracked_files = self.get_untracked_files()?;
144
145 Ok(RepositoryInfo {
146 path: self.path.clone(),
147 head_branch,
148 head_commit,
149 is_dirty,
150 untracked_files,
151 })
152 }
153
154 pub fn get_current_branch(&self) -> Result<String> {
156 let head = self
157 .repo
158 .head()
159 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
160
161 if let Some(name) = head.shorthand() {
162 Ok(name.to_string())
163 } else {
164 let commit = head
166 .peel_to_commit()
167 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))?;
168 Ok(format!("HEAD@{}", commit.id()))
169 }
170 }
171
172 pub fn get_head_commit_hash(&self) -> Result<String> {
174 let head = self
175 .repo
176 .head()
177 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
178
179 let commit = head
180 .peel_to_commit()
181 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))?;
182
183 Ok(commit.id().to_string())
184 }
185
186 pub fn is_dirty(&self) -> Result<bool> {
188 let statuses = self.repo.statuses(None).map_err(CascadeError::Git)?;
189
190 for status in statuses.iter() {
191 let flags = status.status();
192
193 if flags.intersects(
195 git2::Status::INDEX_MODIFIED
196 | git2::Status::INDEX_NEW
197 | git2::Status::INDEX_DELETED
198 | git2::Status::WT_MODIFIED
199 | git2::Status::WT_NEW
200 | git2::Status::WT_DELETED,
201 ) {
202 return Ok(true);
203 }
204 }
205
206 Ok(false)
207 }
208
209 pub fn get_untracked_files(&self) -> Result<Vec<String>> {
211 let statuses = self.repo.statuses(None).map_err(CascadeError::Git)?;
212
213 let mut untracked = Vec::new();
214 for status in statuses.iter() {
215 if status.status().contains(git2::Status::WT_NEW) {
216 if let Some(path) = status.path() {
217 untracked.push(path.to_string());
218 }
219 }
220 }
221
222 Ok(untracked)
223 }
224
225 pub fn create_branch(&self, name: &str, target: Option<&str>) -> Result<()> {
227 let target_commit = if let Some(target) = target {
228 let target_obj = self.repo.revparse_single(target).map_err(|e| {
230 CascadeError::branch(format!("Could not find target '{target}': {e}"))
231 })?;
232 target_obj.peel_to_commit().map_err(|e| {
233 CascadeError::branch(format!("Target '{target}' is not a commit: {e}"))
234 })?
235 } else {
236 let head = self
238 .repo
239 .head()
240 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
241 head.peel_to_commit()
242 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))?
243 };
244
245 self.repo
246 .branch(name, &target_commit, false)
247 .map_err(|e| CascadeError::branch(format!("Could not create branch '{name}': {e}")))?;
248
249 Ok(())
251 }
252
253 pub fn checkout_branch(&self, name: &str) -> Result<()> {
255 self.checkout_branch_with_options(name, false)
256 }
257
258 pub fn checkout_branch_unsafe(&self, name: &str) -> Result<()> {
260 self.checkout_branch_with_options(name, true)
261 }
262
263 fn checkout_branch_with_options(&self, name: &str, force_unsafe: bool) -> Result<()> {
265 info!("Attempting to checkout branch: {}", name);
266
267 if !force_unsafe {
269 let safety_result = self.check_checkout_safety(name)?;
270 if let Some(safety_info) = safety_result {
271 self.handle_checkout_confirmation(name, &safety_info)?;
273 }
274 }
275
276 let branch = self
278 .repo
279 .find_branch(name, git2::BranchType::Local)
280 .map_err(|e| CascadeError::branch(format!("Could not find branch '{name}': {e}")))?;
281
282 let branch_ref = branch.get();
283 let tree = branch_ref.peel_to_tree().map_err(|e| {
284 CascadeError::branch(format!("Could not get tree for branch '{name}': {e}"))
285 })?;
286
287 self.repo
289 .checkout_tree(tree.as_object(), None)
290 .map_err(|e| {
291 CascadeError::branch(format!("Could not checkout branch '{name}': {e}"))
292 })?;
293
294 self.repo
296 .set_head(&format!("refs/heads/{name}"))
297 .map_err(|e| CascadeError::branch(format!("Could not update HEAD to '{name}': {e}")))?;
298
299 Output::success(format!("Switched to branch '{name}'"));
300 Ok(())
301 }
302
303 pub fn checkout_commit(&self, commit_hash: &str) -> Result<()> {
305 self.checkout_commit_with_options(commit_hash, false)
306 }
307
308 pub fn checkout_commit_unsafe(&self, commit_hash: &str) -> Result<()> {
310 self.checkout_commit_with_options(commit_hash, true)
311 }
312
313 fn checkout_commit_with_options(&self, commit_hash: &str, force_unsafe: bool) -> Result<()> {
315 info!("Attempting to checkout commit: {}", commit_hash);
316
317 if !force_unsafe {
319 let safety_result = self.check_checkout_safety(&format!("commit:{commit_hash}"))?;
320 if let Some(safety_info) = safety_result {
321 self.handle_checkout_confirmation(&format!("commit {commit_hash}"), &safety_info)?;
323 }
324 }
325
326 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
327
328 let commit = self.repo.find_commit(oid).map_err(|e| {
329 CascadeError::branch(format!("Could not find commit '{commit_hash}': {e}"))
330 })?;
331
332 let tree = commit.tree().map_err(|e| {
333 CascadeError::branch(format!(
334 "Could not get tree for commit '{commit_hash}': {e}"
335 ))
336 })?;
337
338 self.repo
340 .checkout_tree(tree.as_object(), None)
341 .map_err(|e| {
342 CascadeError::branch(format!("Could not checkout commit '{commit_hash}': {e}"))
343 })?;
344
345 self.repo.set_head_detached(oid).map_err(|e| {
347 CascadeError::branch(format!(
348 "Could not update HEAD to commit '{commit_hash}': {e}"
349 ))
350 })?;
351
352 Output::success(format!(
353 "Checked out commit '{commit_hash}' (detached HEAD)"
354 ));
355 Ok(())
356 }
357
358 pub fn branch_exists(&self, name: &str) -> bool {
360 self.repo.find_branch(name, git2::BranchType::Local).is_ok()
361 }
362
363 pub fn branch_exists_or_fetch(&self, name: &str) -> Result<bool> {
365 if self.repo.find_branch(name, git2::BranchType::Local).is_ok() {
367 return Ok(true);
368 }
369
370 println!("🔍 Branch '{name}' not found locally, trying to fetch from remote...");
372
373 use std::process::Command;
374
375 let fetch_result = Command::new("git")
377 .args(["fetch", "origin", &format!("{name}:{name}")])
378 .current_dir(&self.path)
379 .output();
380
381 match fetch_result {
382 Ok(output) => {
383 if output.status.success() {
384 println!("✅ Successfully fetched '{name}' from origin");
385 return Ok(self.repo.find_branch(name, git2::BranchType::Local).is_ok());
387 } else {
388 let stderr = String::from_utf8_lossy(&output.stderr);
389 tracing::debug!("Failed to fetch branch '{name}': {stderr}");
390 }
391 }
392 Err(e) => {
393 tracing::debug!("Git fetch command failed: {e}");
394 }
395 }
396
397 if name.contains('/') {
399 println!("🔍 Trying alternative fetch patterns...");
400
401 let fetch_all_result = Command::new("git")
403 .args(["fetch", "origin"])
404 .current_dir(&self.path)
405 .output();
406
407 if let Ok(output) = fetch_all_result {
408 if output.status.success() {
409 let checkout_result = Command::new("git")
411 .args(["checkout", "-b", name, &format!("origin/{name}")])
412 .current_dir(&self.path)
413 .output();
414
415 if let Ok(checkout_output) = checkout_result {
416 if checkout_output.status.success() {
417 println!(
418 "✅ Successfully created local branch '{name}' from origin/{name}"
419 );
420 return Ok(true);
421 }
422 }
423 }
424 }
425 }
426
427 Ok(false)
429 }
430
431 pub fn get_branch_commit_hash(&self, branch_name: &str) -> Result<String> {
433 let branch = self
434 .repo
435 .find_branch(branch_name, git2::BranchType::Local)
436 .map_err(|e| {
437 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
438 })?;
439
440 let commit = branch.get().peel_to_commit().map_err(|e| {
441 CascadeError::branch(format!(
442 "Could not get commit for branch '{branch_name}': {e}"
443 ))
444 })?;
445
446 Ok(commit.id().to_string())
447 }
448
449 pub fn list_branches(&self) -> Result<Vec<String>> {
451 let branches = self
452 .repo
453 .branches(Some(git2::BranchType::Local))
454 .map_err(CascadeError::Git)?;
455
456 let mut branch_names = Vec::new();
457 for branch in branches {
458 let (branch, _) = branch.map_err(CascadeError::Git)?;
459 if let Some(name) = branch.name().map_err(CascadeError::Git)? {
460 branch_names.push(name.to_string());
461 }
462 }
463
464 Ok(branch_names)
465 }
466
467 pub fn commit(&self, message: &str) -> Result<String> {
469 self.validate_git_user_config()?;
471
472 let signature = self.get_signature()?;
473 let tree_id = self.get_index_tree()?;
474 let tree = self.repo.find_tree(tree_id).map_err(CascadeError::Git)?;
475
476 let head = self.repo.head().map_err(CascadeError::Git)?;
478 let parent_commit = head.peel_to_commit().map_err(CascadeError::Git)?;
479
480 let commit_id = self
481 .repo
482 .commit(
483 Some("HEAD"),
484 &signature,
485 &signature,
486 message,
487 &tree,
488 &[&parent_commit],
489 )
490 .map_err(CascadeError::Git)?;
491
492 Output::success(format!("Created commit: {commit_id} - {message}"));
493 Ok(commit_id.to_string())
494 }
495
496 pub fn commit_staged_changes(&self, default_message: &str) -> Result<Option<String>> {
498 let staged_files = self.get_staged_files()?;
500 if staged_files.is_empty() {
501 tracing::debug!("No staged changes to commit");
502 return Ok(None);
503 }
504
505 tracing::info!("Committing {} staged files", staged_files.len());
506 let commit_hash = self.commit(default_message)?;
507 Ok(Some(commit_hash))
508 }
509
510 pub fn stage_all(&self) -> Result<()> {
512 let mut index = self.repo.index().map_err(CascadeError::Git)?;
513
514 index
515 .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
516 .map_err(CascadeError::Git)?;
517
518 index.write().map_err(CascadeError::Git)?;
519
520 tracing::debug!("Staged all changes");
521 Ok(())
522 }
523
524 pub fn stage_files(&self, file_paths: &[&str]) -> Result<()> {
526 if file_paths.is_empty() {
527 tracing::debug!("No files to stage");
528 return Ok(());
529 }
530
531 let mut index = self.repo.index().map_err(CascadeError::Git)?;
532
533 for file_path in file_paths {
534 index
535 .add_path(std::path::Path::new(file_path))
536 .map_err(CascadeError::Git)?;
537 }
538
539 index.write().map_err(CascadeError::Git)?;
540
541 tracing::debug!(
542 "Staged {} specific files: {:?}",
543 file_paths.len(),
544 file_paths
545 );
546 Ok(())
547 }
548
549 pub fn stage_conflict_resolved_files(&self) -> Result<()> {
551 let conflicted_files = self.get_conflicted_files()?;
552 if conflicted_files.is_empty() {
553 tracing::debug!("No conflicted files to stage");
554 return Ok(());
555 }
556
557 let file_paths: Vec<&str> = conflicted_files.iter().map(|s| s.as_str()).collect();
558 self.stage_files(&file_paths)?;
559
560 tracing::debug!("Staged {} conflict-resolved files", conflicted_files.len());
561 Ok(())
562 }
563
564 pub fn path(&self) -> &Path {
566 &self.path
567 }
568
569 pub fn commit_exists(&self, commit_hash: &str) -> Result<bool> {
571 match Oid::from_str(commit_hash) {
572 Ok(oid) => match self.repo.find_commit(oid) {
573 Ok(_) => Ok(true),
574 Err(_) => Ok(false),
575 },
576 Err(_) => Ok(false),
577 }
578 }
579
580 pub fn get_head_commit(&self) -> Result<git2::Commit<'_>> {
582 let head = self
583 .repo
584 .head()
585 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
586 head.peel_to_commit()
587 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))
588 }
589
590 pub fn get_commit(&self, commit_hash: &str) -> Result<git2::Commit<'_>> {
592 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
593
594 self.repo.find_commit(oid).map_err(CascadeError::Git)
595 }
596
597 pub fn get_branch_head(&self, branch_name: &str) -> Result<String> {
599 let branch = self
600 .repo
601 .find_branch(branch_name, git2::BranchType::Local)
602 .map_err(|e| {
603 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
604 })?;
605
606 let commit = branch.get().peel_to_commit().map_err(|e| {
607 CascadeError::branch(format!(
608 "Could not get commit for branch '{branch_name}': {e}"
609 ))
610 })?;
611
612 Ok(commit.id().to_string())
613 }
614
615 pub fn validate_git_user_config(&self) -> Result<()> {
617 if let Ok(config) = self.repo.config() {
618 let name_result = config.get_string("user.name");
619 let email_result = config.get_string("user.email");
620
621 if let (Ok(name), Ok(email)) = (name_result, email_result) {
622 if !name.trim().is_empty() && !email.trim().is_empty() {
623 tracing::debug!("Git user config validated: {} <{}>", name, email);
624 return Ok(());
625 }
626 }
627 }
628
629 let is_ci = std::env::var("CI").is_ok();
631
632 if is_ci {
633 tracing::debug!("CI environment - skipping git user config validation");
634 return Ok(());
635 }
636
637 Output::warning("Git user configuration missing or incomplete");
638 Output::info("This can cause cherry-pick and commit operations to fail");
639 Output::info("Please configure git user information:");
640 Output::bullet("git config user.name \"Your Name\"".to_string());
641 Output::bullet("git config user.email \"your.email@example.com\"".to_string());
642 Output::info("Or set globally with the --global flag");
643
644 Ok(())
647 }
648
649 fn get_signature(&self) -> Result<Signature<'_>> {
651 if let Ok(config) = self.repo.config() {
653 let name_result = config.get_string("user.name");
655 let email_result = config.get_string("user.email");
656
657 if let (Ok(name), Ok(email)) = (name_result, email_result) {
658 if !name.trim().is_empty() && !email.trim().is_empty() {
659 tracing::debug!("Using git config: {} <{}>", name, email);
660 return Signature::now(&name, &email).map_err(CascadeError::Git);
661 }
662 } else {
663 tracing::debug!("Git user config incomplete or missing");
664 }
665 }
666
667 let is_ci = std::env::var("CI").is_ok();
669
670 if is_ci {
671 tracing::debug!("CI environment detected, using fallback signature");
672 return Signature::now("Cascade CLI", "cascade@example.com").map_err(CascadeError::Git);
673 }
674
675 tracing::warn!("Git user configuration missing - this can cause commit operations to fail");
677
678 match Signature::now("Cascade CLI", "cascade@example.com") {
680 Ok(sig) => {
681 Output::warning("Git user not configured - using fallback signature");
682 Output::info("For better git history, run:");
683 Output::bullet("git config user.name \"Your Name\"".to_string());
684 Output::bullet("git config user.email \"your.email@example.com\"".to_string());
685 Output::info("Or set it globally with --global flag");
686 Ok(sig)
687 }
688 Err(e) => {
689 Err(CascadeError::branch(format!(
690 "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\""
691 )))
692 }
693 }
694 }
695
696 fn configure_remote_callbacks(&self) -> Result<git2::RemoteCallbacks<'_>> {
699 self.configure_remote_callbacks_with_fallback(false)
700 }
701
702 fn should_retry_with_default_credentials(&self, error: &git2::Error) -> bool {
704 match error.class() {
705 git2::ErrorClass::Http => {
707 match error.code() {
709 git2::ErrorCode::Auth => true,
710 _ => {
711 let error_string = error.to_string();
713 error_string.contains("too many redirects")
714 || error_string.contains("authentication replays")
715 || error_string.contains("authentication required")
716 }
717 }
718 }
719 git2::ErrorClass::Net => {
720 let error_string = error.to_string();
722 error_string.contains("authentication")
723 || error_string.contains("unauthorized")
724 || error_string.contains("forbidden")
725 }
726 _ => false,
727 }
728 }
729
730 fn should_fallback_to_git_cli(&self, error: &git2::Error) -> bool {
732 match error.class() {
733 git2::ErrorClass::Ssl => true,
735
736 git2::ErrorClass::Http if error.code() == git2::ErrorCode::Certificate => true,
738
739 git2::ErrorClass::Ssh => {
741 let error_string = error.to_string();
742 error_string.contains("no callback set")
743 || error_string.contains("authentication required")
744 }
745
746 git2::ErrorClass::Net => {
748 let error_string = error.to_string();
749 error_string.contains("TLS stream")
750 || error_string.contains("SSL")
751 || error_string.contains("proxy")
752 || error_string.contains("firewall")
753 }
754
755 git2::ErrorClass::Http => {
757 let error_string = error.to_string();
758 error_string.contains("TLS stream")
759 || error_string.contains("SSL")
760 || error_string.contains("proxy")
761 }
762
763 _ => false,
764 }
765 }
766
767 fn configure_remote_callbacks_with_fallback(
768 &self,
769 use_default_first: bool,
770 ) -> Result<git2::RemoteCallbacks<'_>> {
771 let mut callbacks = git2::RemoteCallbacks::new();
772
773 let bitbucket_credentials = self.bitbucket_credentials.clone();
775 callbacks.credentials(move |url, username_from_url, allowed_types| {
776 tracing::debug!(
777 "Authentication requested for URL: {}, username: {:?}, allowed_types: {:?}",
778 url,
779 username_from_url,
780 allowed_types
781 );
782
783 if allowed_types.contains(git2::CredentialType::SSH_KEY) {
785 if let Some(username) = username_from_url {
786 tracing::debug!("Trying SSH key authentication for user: {}", username);
787 return git2::Cred::ssh_key_from_agent(username);
788 }
789 }
790
791 if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
793 if use_default_first {
795 tracing::debug!("Corporate network mode: trying DefaultCredentials first");
796 return git2::Cred::default();
797 }
798
799 if url.contains("bitbucket") {
800 if let Some(creds) = &bitbucket_credentials {
801 if let (Some(username), Some(token)) = (&creds.username, &creds.token) {
803 tracing::debug!("Trying Bitbucket username + token authentication");
804 return git2::Cred::userpass_plaintext(username, token);
805 }
806
807 if let Some(token) = &creds.token {
809 tracing::debug!("Trying Bitbucket token-as-username authentication");
810 return git2::Cred::userpass_plaintext(token, "");
811 }
812
813 if let Some(username) = &creds.username {
815 tracing::debug!("Trying Bitbucket username authentication (will use credential helper)");
816 return git2::Cred::username(username);
817 }
818 }
819 }
820
821 tracing::debug!("Trying default credential helper for HTTPS authentication");
823 return git2::Cred::default();
824 }
825
826 tracing::debug!("Using default credential fallback");
828 git2::Cred::default()
829 });
830
831 let mut ssl_configured = false;
836
837 if let Some(ssl_config) = &self.ssl_config {
839 if ssl_config.accept_invalid_certs {
840 Output::warning(
841 "SSL certificate verification DISABLED via Cascade config - this is insecure!",
842 );
843 callbacks.certificate_check(|_cert, _host| {
844 tracing::debug!("⚠️ Accepting invalid certificate for host: {}", _host);
845 Ok(git2::CertificateCheckStatus::CertificateOk)
846 });
847 ssl_configured = true;
848 } else if let Some(ca_path) = &ssl_config.ca_bundle_path {
849 Output::info(format!(
850 "Using custom CA bundle from Cascade config: {ca_path}"
851 ));
852 callbacks.certificate_check(|_cert, host| {
853 tracing::debug!("Using custom CA bundle for host: {}", host);
854 Ok(git2::CertificateCheckStatus::CertificateOk)
855 });
856 ssl_configured = true;
857 }
858 }
859
860 if !ssl_configured {
862 if let Ok(config) = self.repo.config() {
863 let ssl_verify = config.get_bool("http.sslVerify").unwrap_or(true);
864
865 if !ssl_verify {
866 Output::warning(
867 "SSL certificate verification DISABLED via git config - this is insecure!",
868 );
869 callbacks.certificate_check(|_cert, host| {
870 tracing::debug!("⚠️ Bypassing SSL verification for host: {}", host);
871 Ok(git2::CertificateCheckStatus::CertificateOk)
872 });
873 ssl_configured = true;
874 } else if let Ok(ca_path) = config.get_string("http.sslCAInfo") {
875 Output::info(format!("Using custom CA bundle from git config: {ca_path}"));
876 callbacks.certificate_check(|_cert, host| {
877 tracing::debug!("Using git config CA bundle for host: {}", host);
878 Ok(git2::CertificateCheckStatus::CertificateOk)
879 });
880 ssl_configured = true;
881 }
882 }
883 }
884
885 if !ssl_configured {
888 tracing::debug!(
889 "Using system certificate store for SSL verification (default behavior)"
890 );
891
892 if cfg!(target_os = "macos") {
894 tracing::debug!("macOS detected - using default certificate validation");
895 } else {
898 callbacks.certificate_check(|_cert, host| {
900 tracing::debug!("System certificate validation for host: {}", host);
901 Ok(git2::CertificateCheckStatus::CertificatePassthrough)
902 });
903 }
904 }
905
906 Ok(callbacks)
907 }
908
909 fn get_index_tree(&self) -> Result<Oid> {
911 let mut index = self.repo.index().map_err(CascadeError::Git)?;
912
913 index.write_tree().map_err(CascadeError::Git)
914 }
915
916 pub fn get_status(&self) -> Result<git2::Statuses<'_>> {
918 self.repo.statuses(None).map_err(CascadeError::Git)
919 }
920
921 pub fn get_remote_url(&self, name: &str) -> Result<String> {
923 let remote = self.repo.find_remote(name).map_err(CascadeError::Git)?;
924 Ok(remote.url().unwrap_or("unknown").to_string())
925 }
926
927 pub fn cherry_pick(&self, commit_hash: &str) -> Result<String> {
929 tracing::debug!("Cherry-picking commit {}", commit_hash);
930
931 self.validate_git_user_config()?;
933
934 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
935 let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
936
937 let commit_tree = commit.tree().map_err(CascadeError::Git)?;
939
940 let parent_commit = if commit.parent_count() > 0 {
942 commit.parent(0).map_err(CascadeError::Git)?
943 } else {
944 let empty_tree_oid = self.repo.treebuilder(None)?.write()?;
946 let empty_tree = self.repo.find_tree(empty_tree_oid)?;
947 let sig = self.get_signature()?;
948 return self
949 .repo
950 .commit(
951 Some("HEAD"),
952 &sig,
953 &sig,
954 commit.message().unwrap_or("Cherry-picked commit"),
955 &empty_tree,
956 &[],
957 )
958 .map(|oid| oid.to_string())
959 .map_err(CascadeError::Git);
960 };
961
962 let parent_tree = parent_commit.tree().map_err(CascadeError::Git)?;
963
964 let head_commit = self.get_head_commit()?;
966 let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
967
968 let mut index = self
970 .repo
971 .merge_trees(&parent_tree, &head_tree, &commit_tree, None)
972 .map_err(CascadeError::Git)?;
973
974 if index.has_conflicts() {
976 return Err(CascadeError::branch(format!(
977 "Cherry-pick of {commit_hash} has conflicts that need manual resolution"
978 )));
979 }
980
981 let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
983 let merged_tree = self
984 .repo
985 .find_tree(merged_tree_oid)
986 .map_err(CascadeError::Git)?;
987
988 let signature = self.get_signature()?;
990 let message = format!("Cherry-pick: {}", commit.message().unwrap_or(""));
991
992 let new_commit_oid = self
993 .repo
994 .commit(
995 Some("HEAD"),
996 &signature,
997 &signature,
998 &message,
999 &merged_tree,
1000 &[&head_commit],
1001 )
1002 .map_err(CascadeError::Git)?;
1003
1004 tracing::info!("Cherry-picked {} -> {}", commit_hash, new_commit_oid);
1005 Ok(new_commit_oid.to_string())
1006 }
1007
1008 pub fn has_conflicts(&self) -> Result<bool> {
1010 let index = self.repo.index().map_err(CascadeError::Git)?;
1011 Ok(index.has_conflicts())
1012 }
1013
1014 pub fn get_conflicted_files(&self) -> Result<Vec<String>> {
1016 let index = self.repo.index().map_err(CascadeError::Git)?;
1017
1018 let mut conflicts = Vec::new();
1019
1020 let conflict_iter = index.conflicts().map_err(CascadeError::Git)?;
1022
1023 for conflict in conflict_iter {
1024 let conflict = conflict.map_err(CascadeError::Git)?;
1025 if let Some(our) = conflict.our {
1026 if let Ok(path) = std::str::from_utf8(&our.path) {
1027 conflicts.push(path.to_string());
1028 }
1029 } else if let Some(their) = conflict.their {
1030 if let Ok(path) = std::str::from_utf8(&their.path) {
1031 conflicts.push(path.to_string());
1032 }
1033 }
1034 }
1035
1036 Ok(conflicts)
1037 }
1038
1039 pub fn fetch(&self) -> Result<()> {
1041 tracing::info!("Fetching from origin");
1042
1043 let mut remote = self
1044 .repo
1045 .find_remote("origin")
1046 .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
1047
1048 let callbacks = self.configure_remote_callbacks()?;
1050
1051 let mut fetch_options = git2::FetchOptions::new();
1053 fetch_options.remote_callbacks(callbacks);
1054
1055 match remote.fetch::<&str>(&[], Some(&mut fetch_options), None) {
1057 Ok(_) => {
1058 tracing::debug!("Fetch completed successfully");
1059 Ok(())
1060 }
1061 Err(e) => {
1062 if self.should_retry_with_default_credentials(&e) {
1063 tracing::debug!(
1064 "Authentication error detected (class: {:?}, code: {:?}): {}, retrying with DefaultCredentials",
1065 e.class(), e.code(), e
1066 );
1067
1068 let callbacks = self.configure_remote_callbacks_with_fallback(true)?;
1070 let mut fetch_options = git2::FetchOptions::new();
1071 fetch_options.remote_callbacks(callbacks);
1072
1073 match remote.fetch::<&str>(&[], Some(&mut fetch_options), None) {
1074 Ok(_) => {
1075 tracing::debug!("Fetch succeeded with DefaultCredentials");
1076 return Ok(());
1077 }
1078 Err(retry_error) => {
1079 tracing::debug!(
1080 "DefaultCredentials retry failed: {}, falling back to git CLI",
1081 retry_error
1082 );
1083 return self.fetch_with_git_cli();
1084 }
1085 }
1086 }
1087
1088 if self.should_fallback_to_git_cli(&e) {
1089 tracing::debug!(
1090 "Network/SSL error detected (class: {:?}, code: {:?}): {}, falling back to git CLI for fetch operation",
1091 e.class(), e.code(), e
1092 );
1093 return self.fetch_with_git_cli();
1094 }
1095 Err(CascadeError::Git(e))
1096 }
1097 }
1098 }
1099
1100 pub fn pull(&self, branch: &str) -> Result<()> {
1102 tracing::info!("Pulling branch: {}", branch);
1103
1104 match self.fetch() {
1106 Ok(_) => {}
1107 Err(e) => {
1108 let error_string = e.to_string();
1110 if error_string.contains("TLS stream") || error_string.contains("SSL") {
1111 tracing::warn!(
1112 "git2 error detected: {}, falling back to git CLI for pull operation",
1113 e
1114 );
1115 return self.pull_with_git_cli(branch);
1116 }
1117 return Err(e);
1118 }
1119 }
1120
1121 let remote_branch_name = format!("origin/{branch}");
1123 let remote_oid = self
1124 .repo
1125 .refname_to_id(&format!("refs/remotes/{remote_branch_name}"))
1126 .map_err(|e| {
1127 CascadeError::branch(format!("Remote branch {remote_branch_name} not found: {e}"))
1128 })?;
1129
1130 let remote_commit = self
1131 .repo
1132 .find_commit(remote_oid)
1133 .map_err(CascadeError::Git)?;
1134
1135 let head_commit = self.get_head_commit()?;
1137
1138 if head_commit.id() == remote_commit.id() {
1140 tracing::debug!("Already up to date");
1141 return Ok(());
1142 }
1143
1144 let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
1146 let remote_tree = remote_commit.tree().map_err(CascadeError::Git)?;
1147
1148 let merge_base_oid = self
1150 .repo
1151 .merge_base(head_commit.id(), remote_commit.id())
1152 .map_err(CascadeError::Git)?;
1153 let merge_base_commit = self
1154 .repo
1155 .find_commit(merge_base_oid)
1156 .map_err(CascadeError::Git)?;
1157 let merge_base_tree = merge_base_commit.tree().map_err(CascadeError::Git)?;
1158
1159 let mut index = self
1161 .repo
1162 .merge_trees(&merge_base_tree, &head_tree, &remote_tree, None)
1163 .map_err(CascadeError::Git)?;
1164
1165 if index.has_conflicts() {
1166 return Err(CascadeError::branch(
1167 "Pull has conflicts that need manual resolution".to_string(),
1168 ));
1169 }
1170
1171 let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
1173 let merged_tree = self
1174 .repo
1175 .find_tree(merged_tree_oid)
1176 .map_err(CascadeError::Git)?;
1177
1178 let signature = self.get_signature()?;
1179 let message = format!("Merge branch '{branch}' from origin");
1180
1181 self.repo
1182 .commit(
1183 Some("HEAD"),
1184 &signature,
1185 &signature,
1186 &message,
1187 &merged_tree,
1188 &[&head_commit, &remote_commit],
1189 )
1190 .map_err(CascadeError::Git)?;
1191
1192 tracing::info!("Pull completed successfully");
1193 Ok(())
1194 }
1195
1196 pub fn push(&self, branch: &str) -> Result<()> {
1198 let mut remote = self
1201 .repo
1202 .find_remote("origin")
1203 .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
1204
1205 let remote_url = remote.url().unwrap_or("unknown").to_string();
1206 tracing::debug!("Remote URL: {}", remote_url);
1207
1208 let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
1209 tracing::debug!("Push refspec: {}", refspec);
1210
1211 let mut callbacks = self.configure_remote_callbacks()?;
1213
1214 callbacks.push_update_reference(|refname, status| {
1216 if let Some(msg) = status {
1217 tracing::error!("Push failed for ref {}: {}", refname, msg);
1218 return Err(git2::Error::from_str(&format!("Push failed: {msg}")));
1219 }
1220 tracing::debug!("Push succeeded for ref: {}", refname);
1221 Ok(())
1222 });
1223
1224 let mut push_options = git2::PushOptions::new();
1226 push_options.remote_callbacks(callbacks);
1227
1228 match remote.push(&[&refspec], Some(&mut push_options)) {
1230 Ok(_) => {
1231 tracing::info!("Push completed successfully for branch: {}", branch);
1232 Ok(())
1233 }
1234 Err(e) => {
1235 tracing::debug!(
1236 "git2 push error: {} (class: {:?}, code: {:?})",
1237 e,
1238 e.class(),
1239 e.code()
1240 );
1241
1242 if self.should_retry_with_default_credentials(&e) {
1243 tracing::debug!(
1244 "Authentication error detected (class: {:?}, code: {:?}): {}, retrying with DefaultCredentials",
1245 e.class(), e.code(), e
1246 );
1247
1248 let callbacks = self.configure_remote_callbacks_with_fallback(true)?;
1250 let mut push_options = git2::PushOptions::new();
1251 push_options.remote_callbacks(callbacks);
1252
1253 match remote.push(&[&refspec], Some(&mut push_options)) {
1254 Ok(_) => {
1255 tracing::debug!("Push succeeded with DefaultCredentials");
1256 return Ok(());
1257 }
1258 Err(retry_error) => {
1259 tracing::debug!(
1260 "DefaultCredentials retry failed: {}, falling back to git CLI",
1261 retry_error
1262 );
1263 return self.push_with_git_cli(branch);
1264 }
1265 }
1266 }
1267
1268 if self.should_fallback_to_git_cli(&e) {
1269 tracing::debug!(
1270 "Network/SSL error detected (class: {:?}, code: {:?}): {}, falling back to git CLI for push operation",
1271 e.class(), e.code(), e
1272 );
1273 return self.push_with_git_cli(branch);
1274 }
1275
1276 let error_msg = if e.to_string().contains("authentication") {
1278 format!(
1279 "Authentication failed for branch '{branch}'. Try: git push origin {branch}"
1280 )
1281 } else {
1282 format!("Failed to push branch '{branch}': {e}")
1283 };
1284
1285 tracing::error!("{}", error_msg);
1286 Err(CascadeError::branch(error_msg))
1287 }
1288 }
1289 }
1290
1291 fn push_with_git_cli(&self, branch: &str) -> Result<()> {
1294 let output = std::process::Command::new("git")
1295 .args(["push", "origin", branch])
1296 .current_dir(&self.path)
1297 .output()
1298 .map_err(|e| CascadeError::branch(format!("Failed to execute git command: {e}")))?;
1299
1300 if output.status.success() {
1301 Ok(())
1303 } else {
1304 let stderr = String::from_utf8_lossy(&output.stderr);
1305 let _stdout = String::from_utf8_lossy(&output.stdout);
1306 let error_msg = if stderr.contains("SSL_connect") || stderr.contains("SSL_ERROR") {
1308 "Network error: Unable to connect to repository (VPN may be required)".to_string()
1309 } else if stderr.contains("repository") && stderr.contains("not found") {
1310 "Repository not found - check your Bitbucket configuration".to_string()
1311 } else if stderr.contains("authentication") || stderr.contains("403") {
1312 "Authentication failed - check your credentials".to_string()
1313 } else {
1314 stderr.trim().to_string()
1316 };
1317 tracing::error!("{}", error_msg);
1318 Err(CascadeError::branch(error_msg))
1319 }
1320 }
1321
1322 fn fetch_with_git_cli(&self) -> Result<()> {
1325 tracing::info!("Using git CLI fallback for fetch operation");
1326
1327 let output = std::process::Command::new("git")
1328 .args(["fetch", "origin"])
1329 .current_dir(&self.path)
1330 .output()
1331 .map_err(|e| {
1332 CascadeError::Git(git2::Error::from_str(&format!(
1333 "Failed to execute git command: {e}"
1334 )))
1335 })?;
1336
1337 if output.status.success() {
1338 tracing::info!("✅ Git CLI fetch succeeded");
1339 Ok(())
1340 } else {
1341 let stderr = String::from_utf8_lossy(&output.stderr);
1342 let stdout = String::from_utf8_lossy(&output.stdout);
1343 let error_msg = format!(
1344 "Git CLI fetch failed: {}\nStdout: {}\nStderr: {}",
1345 output.status, stdout, stderr
1346 );
1347 tracing::error!("{}", error_msg);
1348 Err(CascadeError::Git(git2::Error::from_str(&error_msg)))
1349 }
1350 }
1351
1352 fn pull_with_git_cli(&self, branch: &str) -> Result<()> {
1355 tracing::info!("Using git CLI fallback for pull operation: {}", branch);
1356
1357 let output = std::process::Command::new("git")
1358 .args(["pull", "origin", branch])
1359 .current_dir(&self.path)
1360 .output()
1361 .map_err(|e| {
1362 CascadeError::Git(git2::Error::from_str(&format!(
1363 "Failed to execute git command: {e}"
1364 )))
1365 })?;
1366
1367 if output.status.success() {
1368 tracing::info!("✅ Git CLI pull succeeded for branch: {}", branch);
1369 Ok(())
1370 } else {
1371 let stderr = String::from_utf8_lossy(&output.stderr);
1372 let stdout = String::from_utf8_lossy(&output.stdout);
1373 let error_msg = format!(
1374 "Git CLI pull failed for branch '{}': {}\nStdout: {}\nStderr: {}",
1375 branch, output.status, stdout, stderr
1376 );
1377 tracing::error!("{}", error_msg);
1378 Err(CascadeError::Git(git2::Error::from_str(&error_msg)))
1379 }
1380 }
1381
1382 fn force_push_with_git_cli(&self, branch: &str) -> Result<()> {
1385 tracing::info!(
1386 "Using git CLI fallback for force push operation: {}",
1387 branch
1388 );
1389
1390 let output = std::process::Command::new("git")
1391 .args(["push", "--force", "origin", branch])
1392 .current_dir(&self.path)
1393 .output()
1394 .map_err(|e| CascadeError::branch(format!("Failed to execute git command: {e}")))?;
1395
1396 if output.status.success() {
1397 tracing::info!("✅ Git CLI force push succeeded for branch: {}", branch);
1398 Ok(())
1399 } else {
1400 let stderr = String::from_utf8_lossy(&output.stderr);
1401 let stdout = String::from_utf8_lossy(&output.stdout);
1402 let error_msg = format!(
1403 "Git CLI force push failed for branch '{}': {}\nStdout: {}\nStderr: {}",
1404 branch, output.status, stdout, stderr
1405 );
1406 tracing::error!("{}", error_msg);
1407 Err(CascadeError::branch(error_msg))
1408 }
1409 }
1410
1411 pub fn delete_branch(&self, name: &str) -> Result<()> {
1413 self.delete_branch_with_options(name, false)
1414 }
1415
1416 pub fn delete_branch_unsafe(&self, name: &str) -> Result<()> {
1418 self.delete_branch_with_options(name, true)
1419 }
1420
1421 fn delete_branch_with_options(&self, name: &str, force_unsafe: bool) -> Result<()> {
1423 info!("Attempting to delete branch: {}", name);
1424
1425 if !force_unsafe {
1427 let safety_result = self.check_branch_deletion_safety(name)?;
1428 if let Some(safety_info) = safety_result {
1429 self.handle_branch_deletion_confirmation(name, &safety_info)?;
1431 }
1432 }
1433
1434 let mut branch = self
1435 .repo
1436 .find_branch(name, git2::BranchType::Local)
1437 .map_err(|e| CascadeError::branch(format!("Could not find branch '{name}': {e}")))?;
1438
1439 branch
1440 .delete()
1441 .map_err(|e| CascadeError::branch(format!("Could not delete branch '{name}': {e}")))?;
1442
1443 info!("Successfully deleted branch '{}'", name);
1444 Ok(())
1445 }
1446
1447 pub fn get_commits_between(&self, from: &str, to: &str) -> Result<Vec<git2::Commit<'_>>> {
1449 let from_oid = self
1450 .repo
1451 .refname_to_id(&format!("refs/heads/{from}"))
1452 .or_else(|_| Oid::from_str(from))
1453 .map_err(|e| CascadeError::branch(format!("Invalid from reference '{from}': {e}")))?;
1454
1455 let to_oid = self
1456 .repo
1457 .refname_to_id(&format!("refs/heads/{to}"))
1458 .or_else(|_| Oid::from_str(to))
1459 .map_err(|e| CascadeError::branch(format!("Invalid to reference '{to}': {e}")))?;
1460
1461 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
1462
1463 revwalk.push(to_oid).map_err(CascadeError::Git)?;
1464 revwalk.hide(from_oid).map_err(CascadeError::Git)?;
1465
1466 let mut commits = Vec::new();
1467 for oid in revwalk {
1468 let oid = oid.map_err(CascadeError::Git)?;
1469 let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
1470 commits.push(commit);
1471 }
1472
1473 Ok(commits)
1474 }
1475
1476 pub fn force_push_branch(&self, target_branch: &str, source_branch: &str) -> Result<()> {
1479 self.force_push_branch_with_options(target_branch, source_branch, false)
1480 }
1481
1482 pub fn force_push_branch_unsafe(&self, target_branch: &str, source_branch: &str) -> Result<()> {
1484 self.force_push_branch_with_options(target_branch, source_branch, true)
1485 }
1486
1487 fn force_push_branch_with_options(
1489 &self,
1490 target_branch: &str,
1491 source_branch: &str,
1492 force_unsafe: bool,
1493 ) -> Result<()> {
1494 info!(
1495 "Force pushing {} content to {} to preserve PR history",
1496 source_branch, target_branch
1497 );
1498
1499 if !force_unsafe {
1501 let safety_result = self.check_force_push_safety_enhanced(target_branch)?;
1502 if let Some(backup_info) = safety_result {
1503 self.create_backup_branch(target_branch, &backup_info.remote_commit_id)?;
1505 info!(
1506 "✅ Created backup branch: {}",
1507 backup_info.backup_branch_name
1508 );
1509 }
1510 }
1511
1512 let source_ref = self
1514 .repo
1515 .find_reference(&format!("refs/heads/{source_branch}"))
1516 .map_err(|e| {
1517 CascadeError::config(format!("Failed to find source branch {source_branch}: {e}"))
1518 })?;
1519 let _source_commit = source_ref.peel_to_commit().map_err(|e| {
1520 CascadeError::config(format!(
1521 "Failed to get commit for source branch {source_branch}: {e}"
1522 ))
1523 })?;
1524
1525 let mut remote = self
1527 .repo
1528 .find_remote("origin")
1529 .map_err(|e| CascadeError::config(format!("Failed to find origin remote: {e}")))?;
1530
1531 let refspec = format!("+refs/heads/{source_branch}:refs/heads/{target_branch}");
1533
1534 let callbacks = self.configure_remote_callbacks()?;
1536
1537 let mut push_options = git2::PushOptions::new();
1539 push_options.remote_callbacks(callbacks);
1540
1541 match remote.push(&[&refspec], Some(&mut push_options)) {
1542 Ok(_) => {}
1543 Err(e) => {
1544 if self.should_retry_with_default_credentials(&e) {
1545 tracing::debug!(
1546 "Authentication error detected (class: {:?}, code: {:?}): {}, retrying with DefaultCredentials",
1547 e.class(), e.code(), e
1548 );
1549
1550 let callbacks = self.configure_remote_callbacks_with_fallback(true)?;
1552 let mut push_options = git2::PushOptions::new();
1553 push_options.remote_callbacks(callbacks);
1554
1555 match remote.push(&[&refspec], Some(&mut push_options)) {
1556 Ok(_) => {
1557 tracing::debug!("Force push succeeded with DefaultCredentials");
1558 }
1560 Err(retry_error) => {
1561 tracing::debug!(
1562 "DefaultCredentials retry failed: {}, falling back to git CLI",
1563 retry_error
1564 );
1565 return self.force_push_with_git_cli(target_branch);
1566 }
1567 }
1568 } else if self.should_fallback_to_git_cli(&e) {
1569 tracing::debug!(
1570 "Network/SSL error detected (class: {:?}, code: {:?}): {}, falling back to git CLI for force push operation",
1571 e.class(), e.code(), e
1572 );
1573 return self.force_push_with_git_cli(target_branch);
1574 } else {
1575 return Err(CascadeError::config(format!(
1576 "Failed to force push {target_branch}: {e}"
1577 )));
1578 }
1579 }
1580 }
1581
1582 info!(
1583 "✅ Successfully force pushed {} to preserve PR history",
1584 target_branch
1585 );
1586 Ok(())
1587 }
1588
1589 fn check_force_push_safety_enhanced(
1592 &self,
1593 target_branch: &str,
1594 ) -> Result<Option<ForceBackupInfo>> {
1595 match self.fetch() {
1597 Ok(_) => {}
1598 Err(e) => {
1599 warn!("Could not fetch latest changes for safety check: {}", e);
1601 }
1602 }
1603
1604 let remote_ref = format!("refs/remotes/origin/{target_branch}");
1606 let local_ref = format!("refs/heads/{target_branch}");
1607
1608 let local_commit = match self.repo.find_reference(&local_ref) {
1610 Ok(reference) => reference.peel_to_commit().ok(),
1611 Err(_) => None,
1612 };
1613
1614 let remote_commit = match self.repo.find_reference(&remote_ref) {
1615 Ok(reference) => reference.peel_to_commit().ok(),
1616 Err(_) => None,
1617 };
1618
1619 if let (Some(local), Some(remote)) = (local_commit, remote_commit) {
1621 if local.id() != remote.id() {
1622 let merge_base_oid = self
1624 .repo
1625 .merge_base(local.id(), remote.id())
1626 .map_err(|e| CascadeError::config(format!("Failed to find merge base: {e}")))?;
1627
1628 if merge_base_oid != remote.id() {
1630 let commits_to_lose = self.count_commits_between(
1631 &merge_base_oid.to_string(),
1632 &remote.id().to_string(),
1633 )?;
1634
1635 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1637 let backup_branch_name = format!("{target_branch}_backup_{timestamp}");
1638
1639 warn!(
1640 "⚠️ Force push to '{}' would overwrite {} commits on remote",
1641 target_branch, commits_to_lose
1642 );
1643
1644 if std::env::var("CI").is_ok() || std::env::var("FORCE_PUSH_NO_CONFIRM").is_ok()
1646 {
1647 info!(
1648 "Non-interactive environment detected, proceeding with backup creation"
1649 );
1650 return Ok(Some(ForceBackupInfo {
1651 backup_branch_name,
1652 remote_commit_id: remote.id().to_string(),
1653 commits_that_would_be_lost: commits_to_lose,
1654 }));
1655 }
1656
1657 println!("\n⚠️ FORCE PUSH WARNING ⚠️");
1659 println!("Force push to '{target_branch}' would overwrite {commits_to_lose} commits on remote:");
1660
1661 match self
1663 .get_commits_between(&merge_base_oid.to_string(), &remote.id().to_string())
1664 {
1665 Ok(commits) => {
1666 println!("\nCommits that would be lost:");
1667 for (i, commit) in commits.iter().take(5).enumerate() {
1668 let short_hash = &commit.id().to_string()[..8];
1669 let summary = commit.summary().unwrap_or("<no message>");
1670 println!(" {}. {} - {}", i + 1, short_hash, summary);
1671 }
1672 if commits.len() > 5 {
1673 println!(" ... and {} more commits", commits.len() - 5);
1674 }
1675 }
1676 Err(_) => {
1677 println!(" (Unable to retrieve commit details)");
1678 }
1679 }
1680
1681 println!("\nA backup branch '{backup_branch_name}' will be created before proceeding.");
1682
1683 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
1684 .with_prompt("Do you want to proceed with the force push?")
1685 .default(false)
1686 .interact()
1687 .map_err(|e| {
1688 CascadeError::config(format!("Failed to get user confirmation: {e}"))
1689 })?;
1690
1691 if !confirmed {
1692 return Err(CascadeError::config(
1693 "Force push cancelled by user. Use --force to bypass this check."
1694 .to_string(),
1695 ));
1696 }
1697
1698 return Ok(Some(ForceBackupInfo {
1699 backup_branch_name,
1700 remote_commit_id: remote.id().to_string(),
1701 commits_that_would_be_lost: commits_to_lose,
1702 }));
1703 }
1704 }
1705 }
1706
1707 Ok(None)
1708 }
1709
1710 fn create_backup_branch(&self, original_branch: &str, remote_commit_id: &str) -> Result<()> {
1712 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1713 let backup_branch_name = format!("{original_branch}_backup_{timestamp}");
1714
1715 let commit_oid = Oid::from_str(remote_commit_id).map_err(|e| {
1717 CascadeError::config(format!("Invalid commit ID {remote_commit_id}: {e}"))
1718 })?;
1719
1720 let commit = self.repo.find_commit(commit_oid).map_err(|e| {
1722 CascadeError::config(format!("Failed to find commit {remote_commit_id}: {e}"))
1723 })?;
1724
1725 self.repo
1727 .branch(&backup_branch_name, &commit, false)
1728 .map_err(|e| {
1729 CascadeError::config(format!(
1730 "Failed to create backup branch {backup_branch_name}: {e}"
1731 ))
1732 })?;
1733
1734 info!(
1735 "✅ Created backup branch '{}' pointing to {}",
1736 backup_branch_name,
1737 &remote_commit_id[..8]
1738 );
1739 Ok(())
1740 }
1741
1742 fn check_branch_deletion_safety(
1745 &self,
1746 branch_name: &str,
1747 ) -> Result<Option<BranchDeletionSafety>> {
1748 match self.fetch() {
1750 Ok(_) => {}
1751 Err(e) => {
1752 warn!(
1753 "Could not fetch latest changes for branch deletion safety check: {}",
1754 e
1755 );
1756 }
1757 }
1758
1759 let branch = self
1761 .repo
1762 .find_branch(branch_name, git2::BranchType::Local)
1763 .map_err(|e| {
1764 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
1765 })?;
1766
1767 let _branch_commit = branch.get().peel_to_commit().map_err(|e| {
1768 CascadeError::branch(format!(
1769 "Could not get commit for branch '{branch_name}': {e}"
1770 ))
1771 })?;
1772
1773 let main_branch_name = self.detect_main_branch()?;
1775
1776 let is_merged_to_main = self.is_branch_merged_to_main(branch_name, &main_branch_name)?;
1778
1779 let remote_tracking_branch = self.get_remote_tracking_branch(branch_name);
1781
1782 let mut unpushed_commits = Vec::new();
1783
1784 if let Some(ref remote_branch) = remote_tracking_branch {
1786 match self.get_commits_between(remote_branch, branch_name) {
1787 Ok(commits) => {
1788 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1789 }
1790 Err(_) => {
1791 if !is_merged_to_main {
1793 if let Ok(commits) =
1794 self.get_commits_between(&main_branch_name, branch_name)
1795 {
1796 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1797 }
1798 }
1799 }
1800 }
1801 } else if !is_merged_to_main {
1802 if let Ok(commits) = self.get_commits_between(&main_branch_name, branch_name) {
1804 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1805 }
1806 }
1807
1808 if !unpushed_commits.is_empty() || (!is_merged_to_main && remote_tracking_branch.is_none())
1810 {
1811 Ok(Some(BranchDeletionSafety {
1812 unpushed_commits,
1813 remote_tracking_branch,
1814 is_merged_to_main,
1815 main_branch_name,
1816 }))
1817 } else {
1818 Ok(None)
1819 }
1820 }
1821
1822 fn handle_branch_deletion_confirmation(
1824 &self,
1825 branch_name: &str,
1826 safety_info: &BranchDeletionSafety,
1827 ) -> Result<()> {
1828 if std::env::var("CI").is_ok() || std::env::var("BRANCH_DELETE_NO_CONFIRM").is_ok() {
1830 return Err(CascadeError::branch(
1831 format!(
1832 "Branch '{branch_name}' has {} unpushed commits and cannot be deleted in non-interactive mode. Use --force to override.",
1833 safety_info.unpushed_commits.len()
1834 )
1835 ));
1836 }
1837
1838 println!("\n⚠️ BRANCH DELETION WARNING ⚠️");
1840 println!("Branch '{branch_name}' has potential issues:");
1841
1842 if !safety_info.unpushed_commits.is_empty() {
1843 println!(
1844 "\n🔍 Unpushed commits ({} total):",
1845 safety_info.unpushed_commits.len()
1846 );
1847
1848 for (i, commit_id) in safety_info.unpushed_commits.iter().take(5).enumerate() {
1850 if let Ok(commit) = self.repo.find_commit(Oid::from_str(commit_id).unwrap()) {
1851 let short_hash = &commit_id[..8];
1852 let summary = commit.summary().unwrap_or("<no message>");
1853 println!(" {}. {} - {}", i + 1, short_hash, summary);
1854 }
1855 }
1856
1857 if safety_info.unpushed_commits.len() > 5 {
1858 println!(
1859 " ... and {} more commits",
1860 safety_info.unpushed_commits.len() - 5
1861 );
1862 }
1863 }
1864
1865 if !safety_info.is_merged_to_main {
1866 println!("\n📋 Branch status:");
1867 println!(" • Not merged to '{}'", safety_info.main_branch_name);
1868 if let Some(ref remote) = safety_info.remote_tracking_branch {
1869 println!(" • Remote tracking branch: {remote}");
1870 } else {
1871 println!(" • No remote tracking branch");
1872 }
1873 }
1874
1875 println!("\n💡 Safer alternatives:");
1876 if !safety_info.unpushed_commits.is_empty() {
1877 if let Some(ref _remote) = safety_info.remote_tracking_branch {
1878 println!(" • Push commits first: git push origin {branch_name}");
1879 } else {
1880 println!(" • Create and push to remote: git push -u origin {branch_name}");
1881 }
1882 }
1883 if !safety_info.is_merged_to_main {
1884 println!(
1885 " • Merge to {} first: git checkout {} && git merge {branch_name}",
1886 safety_info.main_branch_name, safety_info.main_branch_name
1887 );
1888 }
1889
1890 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
1891 .with_prompt("Do you want to proceed with deleting this branch?")
1892 .default(false)
1893 .interact()
1894 .map_err(|e| CascadeError::branch(format!("Failed to get user confirmation: {e}")))?;
1895
1896 if !confirmed {
1897 return Err(CascadeError::branch(
1898 "Branch deletion cancelled by user. Use --force to bypass this check.".to_string(),
1899 ));
1900 }
1901
1902 Ok(())
1903 }
1904
1905 pub fn detect_main_branch(&self) -> Result<String> {
1907 let main_candidates = ["main", "master", "develop", "trunk"];
1908
1909 for candidate in &main_candidates {
1910 if self
1911 .repo
1912 .find_branch(candidate, git2::BranchType::Local)
1913 .is_ok()
1914 {
1915 return Ok(candidate.to_string());
1916 }
1917 }
1918
1919 if let Ok(head) = self.repo.head() {
1921 if let Some(name) = head.shorthand() {
1922 return Ok(name.to_string());
1923 }
1924 }
1925
1926 Ok("main".to_string())
1928 }
1929
1930 fn is_branch_merged_to_main(&self, branch_name: &str, main_branch: &str) -> Result<bool> {
1932 match self.get_commits_between(main_branch, branch_name) {
1934 Ok(commits) => Ok(commits.is_empty()),
1935 Err(_) => {
1936 Ok(false)
1938 }
1939 }
1940 }
1941
1942 fn get_remote_tracking_branch(&self, branch_name: &str) -> Option<String> {
1944 let remote_candidates = [
1946 format!("origin/{branch_name}"),
1947 format!("remotes/origin/{branch_name}"),
1948 ];
1949
1950 for candidate in &remote_candidates {
1951 if self
1952 .repo
1953 .find_reference(&format!(
1954 "refs/remotes/{}",
1955 candidate.replace("remotes/", "")
1956 ))
1957 .is_ok()
1958 {
1959 return Some(candidate.clone());
1960 }
1961 }
1962
1963 None
1964 }
1965
1966 fn check_checkout_safety(&self, _target: &str) -> Result<Option<CheckoutSafety>> {
1968 let is_dirty = self.is_dirty()?;
1970 if !is_dirty {
1971 return Ok(None);
1973 }
1974
1975 let current_branch = self.get_current_branch().ok();
1977
1978 let modified_files = self.get_modified_files()?;
1980 let staged_files = self.get_staged_files()?;
1981 let untracked_files = self.get_untracked_files()?;
1982
1983 let has_uncommitted_changes = !modified_files.is_empty() || !staged_files.is_empty();
1984
1985 if has_uncommitted_changes || !untracked_files.is_empty() {
1986 return Ok(Some(CheckoutSafety {
1987 has_uncommitted_changes,
1988 modified_files,
1989 staged_files,
1990 untracked_files,
1991 stash_created: None,
1992 current_branch,
1993 }));
1994 }
1995
1996 Ok(None)
1997 }
1998
1999 fn handle_checkout_confirmation(
2001 &self,
2002 target: &str,
2003 safety_info: &CheckoutSafety,
2004 ) -> Result<()> {
2005 let is_ci = std::env::var("CI").is_ok();
2007 let no_confirm = std::env::var("CHECKOUT_NO_CONFIRM").is_ok();
2008 let is_non_interactive = is_ci || no_confirm;
2009
2010 if is_non_interactive {
2011 return Err(CascadeError::branch(
2012 format!(
2013 "Cannot checkout '{target}' with uncommitted changes in non-interactive mode. Commit your changes or use stash first."
2014 )
2015 ));
2016 }
2017
2018 println!("\n⚠️ CHECKOUT WARNING ⚠️");
2020 println!("You have uncommitted changes that could be lost:");
2021
2022 if !safety_info.modified_files.is_empty() {
2023 println!(
2024 "\n📝 Modified files ({}):",
2025 safety_info.modified_files.len()
2026 );
2027 for file in safety_info.modified_files.iter().take(10) {
2028 println!(" - {file}");
2029 }
2030 if safety_info.modified_files.len() > 10 {
2031 println!(" ... and {} more", safety_info.modified_files.len() - 10);
2032 }
2033 }
2034
2035 if !safety_info.staged_files.is_empty() {
2036 println!("\n📁 Staged files ({}):", safety_info.staged_files.len());
2037 for file in safety_info.staged_files.iter().take(10) {
2038 println!(" - {file}");
2039 }
2040 if safety_info.staged_files.len() > 10 {
2041 println!(" ... and {} more", safety_info.staged_files.len() - 10);
2042 }
2043 }
2044
2045 if !safety_info.untracked_files.is_empty() {
2046 println!(
2047 "\n❓ Untracked files ({}):",
2048 safety_info.untracked_files.len()
2049 );
2050 for file in safety_info.untracked_files.iter().take(5) {
2051 println!(" - {file}");
2052 }
2053 if safety_info.untracked_files.len() > 5 {
2054 println!(" ... and {} more", safety_info.untracked_files.len() - 5);
2055 }
2056 }
2057
2058 println!("\n🔄 Options:");
2059 println!("1. Stash changes and checkout (recommended)");
2060 println!("2. Force checkout (WILL LOSE UNCOMMITTED CHANGES)");
2061 println!("3. Cancel checkout");
2062
2063 let selection = Select::with_theme(&ColorfulTheme::default())
2065 .with_prompt("Choose an action")
2066 .items(&[
2067 "Stash changes and checkout (recommended)",
2068 "Force checkout (WILL LOSE UNCOMMITTED CHANGES)",
2069 "Cancel checkout",
2070 ])
2071 .default(0)
2072 .interact()
2073 .map_err(|e| CascadeError::branch(format!("Could not get user selection: {e}")))?;
2074
2075 match selection {
2076 0 => {
2077 let stash_message = format!(
2079 "Auto-stash before checkout to {} at {}",
2080 target,
2081 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
2082 );
2083
2084 match self.create_stash(&stash_message) {
2085 Ok(stash_id) => {
2086 println!("✅ Created stash: {stash_message} ({stash_id})");
2087 println!("💡 You can restore with: git stash pop");
2088 }
2089 Err(e) => {
2090 println!("❌ Failed to create stash: {e}");
2091
2092 use dialoguer::Select;
2094 let stash_failed_options = vec![
2095 "Commit staged changes and proceed",
2096 "Force checkout (WILL LOSE CHANGES)",
2097 "Cancel and handle manually",
2098 ];
2099
2100 let stash_selection = Select::with_theme(&ColorfulTheme::default())
2101 .with_prompt("Stash failed. What would you like to do?")
2102 .items(&stash_failed_options)
2103 .default(0)
2104 .interact()
2105 .map_err(|e| {
2106 CascadeError::branch(format!("Could not get user selection: {e}"))
2107 })?;
2108
2109 match stash_selection {
2110 0 => {
2111 let staged_files = self.get_staged_files()?;
2113 if !staged_files.is_empty() {
2114 println!(
2115 "📝 Committing {} staged files...",
2116 staged_files.len()
2117 );
2118 match self
2119 .commit_staged_changes("WIP: Auto-commit before checkout")
2120 {
2121 Ok(Some(commit_hash)) => {
2122 println!(
2123 "✅ Committed staged changes as {}",
2124 &commit_hash[..8]
2125 );
2126 println!("💡 You can undo with: git reset HEAD~1");
2127 }
2128 Ok(None) => {
2129 println!("ℹ️ No staged changes found to commit");
2130 }
2131 Err(commit_err) => {
2132 println!(
2133 "❌ Failed to commit staged changes: {commit_err}"
2134 );
2135 return Err(CascadeError::branch(
2136 "Could not commit staged changes".to_string(),
2137 ));
2138 }
2139 }
2140 } else {
2141 println!("ℹ️ No staged changes to commit");
2142 }
2143 }
2144 1 => {
2145 println!("⚠️ Proceeding with force checkout - uncommitted changes will be lost!");
2147 }
2148 2 => {
2149 return Err(CascadeError::branch(
2151 "Checkout cancelled. Please handle changes manually and try again.".to_string(),
2152 ));
2153 }
2154 _ => unreachable!(),
2155 }
2156 }
2157 }
2158 }
2159 1 => {
2160 println!("⚠️ Proceeding with force checkout - uncommitted changes will be lost!");
2162 }
2163 2 => {
2164 return Err(CascadeError::branch(
2166 "Checkout cancelled by user".to_string(),
2167 ));
2168 }
2169 _ => unreachable!(),
2170 }
2171
2172 Ok(())
2173 }
2174
2175 fn create_stash(&self, message: &str) -> Result<String> {
2177 tracing::info!("Creating stash: {}", message);
2178
2179 let output = std::process::Command::new("git")
2181 .args(["stash", "push", "-m", message])
2182 .current_dir(&self.path)
2183 .output()
2184 .map_err(|e| {
2185 CascadeError::branch(format!("Failed to execute git stash command: {e}"))
2186 })?;
2187
2188 if output.status.success() {
2189 let stdout = String::from_utf8_lossy(&output.stdout);
2190
2191 let stash_id = if stdout.contains("Saved working directory") {
2193 let stash_list_output = std::process::Command::new("git")
2195 .args(["stash", "list", "-n", "1", "--format=%H"])
2196 .current_dir(&self.path)
2197 .output()
2198 .map_err(|e| CascadeError::branch(format!("Failed to get stash ID: {e}")))?;
2199
2200 if stash_list_output.status.success() {
2201 String::from_utf8_lossy(&stash_list_output.stdout)
2202 .trim()
2203 .to_string()
2204 } else {
2205 "stash@{0}".to_string() }
2207 } else {
2208 "stash@{0}".to_string() };
2210
2211 tracing::info!("✅ Created stash: {} ({})", message, stash_id);
2212 Ok(stash_id)
2213 } else {
2214 let stderr = String::from_utf8_lossy(&output.stderr);
2215 let stdout = String::from_utf8_lossy(&output.stdout);
2216
2217 if stderr.contains("No local changes to save")
2219 || stdout.contains("No local changes to save")
2220 {
2221 return Err(CascadeError::branch("No local changes to save".to_string()));
2222 }
2223
2224 Err(CascadeError::branch(format!(
2225 "Failed to create stash: {}\nStderr: {}\nStdout: {}",
2226 output.status, stderr, stdout
2227 )))
2228 }
2229 }
2230
2231 fn get_modified_files(&self) -> Result<Vec<String>> {
2233 let mut opts = git2::StatusOptions::new();
2234 opts.include_untracked(false).include_ignored(false);
2235
2236 let statuses = self
2237 .repo
2238 .statuses(Some(&mut opts))
2239 .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
2240
2241 let mut modified_files = Vec::new();
2242 for status in statuses.iter() {
2243 let flags = status.status();
2244 if flags.contains(git2::Status::WT_MODIFIED) || flags.contains(git2::Status::WT_DELETED)
2245 {
2246 if let Some(path) = status.path() {
2247 modified_files.push(path.to_string());
2248 }
2249 }
2250 }
2251
2252 Ok(modified_files)
2253 }
2254
2255 pub fn get_staged_files(&self) -> Result<Vec<String>> {
2257 let mut opts = git2::StatusOptions::new();
2258 opts.include_untracked(false).include_ignored(false);
2259
2260 let statuses = self
2261 .repo
2262 .statuses(Some(&mut opts))
2263 .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
2264
2265 let mut staged_files = Vec::new();
2266 for status in statuses.iter() {
2267 let flags = status.status();
2268 if flags.contains(git2::Status::INDEX_MODIFIED)
2269 || flags.contains(git2::Status::INDEX_NEW)
2270 || flags.contains(git2::Status::INDEX_DELETED)
2271 {
2272 if let Some(path) = status.path() {
2273 staged_files.push(path.to_string());
2274 }
2275 }
2276 }
2277
2278 Ok(staged_files)
2279 }
2280
2281 fn count_commits_between(&self, from: &str, to: &str) -> Result<usize> {
2283 let commits = self.get_commits_between(from, to)?;
2284 Ok(commits.len())
2285 }
2286
2287 pub fn resolve_reference(&self, reference: &str) -> Result<git2::Commit<'_>> {
2289 if let Ok(oid) = Oid::from_str(reference) {
2291 if let Ok(commit) = self.repo.find_commit(oid) {
2292 return Ok(commit);
2293 }
2294 }
2295
2296 let obj = self.repo.revparse_single(reference).map_err(|e| {
2298 CascadeError::branch(format!("Could not resolve reference '{reference}': {e}"))
2299 })?;
2300
2301 obj.peel_to_commit().map_err(|e| {
2302 CascadeError::branch(format!(
2303 "Reference '{reference}' does not point to a commit: {e}"
2304 ))
2305 })
2306 }
2307
2308 pub fn reset_soft(&self, target_ref: &str) -> Result<()> {
2310 let target_commit = self.resolve_reference(target_ref)?;
2311
2312 self.repo
2313 .reset(target_commit.as_object(), git2::ResetType::Soft, None)
2314 .map_err(CascadeError::Git)?;
2315
2316 Ok(())
2317 }
2318
2319 pub fn find_branch_containing_commit(&self, commit_hash: &str) -> Result<String> {
2321 let oid = Oid::from_str(commit_hash).map_err(|e| {
2322 CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
2323 })?;
2324
2325 let branches = self
2327 .repo
2328 .branches(Some(git2::BranchType::Local))
2329 .map_err(CascadeError::Git)?;
2330
2331 for branch_result in branches {
2332 let (branch, _) = branch_result.map_err(CascadeError::Git)?;
2333
2334 if let Some(branch_name) = branch.name().map_err(CascadeError::Git)? {
2335 if let Ok(branch_head) = branch.get().peel_to_commit() {
2337 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
2339 revwalk.push(branch_head.id()).map_err(CascadeError::Git)?;
2340
2341 for commit_oid in revwalk {
2342 let commit_oid = commit_oid.map_err(CascadeError::Git)?;
2343 if commit_oid == oid {
2344 return Ok(branch_name.to_string());
2345 }
2346 }
2347 }
2348 }
2349 }
2350
2351 Err(CascadeError::branch(format!(
2353 "Commit {commit_hash} not found in any local branch"
2354 )))
2355 }
2356
2357 pub async fn fetch_async(&self) -> Result<()> {
2361 let repo_path = self.path.clone();
2362 crate::utils::async_ops::run_git_operation(move || {
2363 let repo = GitRepository::open(&repo_path)?;
2364 repo.fetch()
2365 })
2366 .await
2367 }
2368
2369 pub async fn pull_async(&self, branch: &str) -> Result<()> {
2371 let repo_path = self.path.clone();
2372 let branch_name = branch.to_string();
2373 crate::utils::async_ops::run_git_operation(move || {
2374 let repo = GitRepository::open(&repo_path)?;
2375 repo.pull(&branch_name)
2376 })
2377 .await
2378 }
2379
2380 pub async fn push_branch_async(&self, branch_name: &str) -> Result<()> {
2382 let repo_path = self.path.clone();
2383 let branch = branch_name.to_string();
2384 crate::utils::async_ops::run_git_operation(move || {
2385 let repo = GitRepository::open(&repo_path)?;
2386 repo.push(&branch)
2387 })
2388 .await
2389 }
2390
2391 pub async fn cherry_pick_commit_async(&self, commit_hash: &str) -> Result<String> {
2393 let repo_path = self.path.clone();
2394 let hash = commit_hash.to_string();
2395 crate::utils::async_ops::run_git_operation(move || {
2396 let repo = GitRepository::open(&repo_path)?;
2397 repo.cherry_pick(&hash)
2398 })
2399 .await
2400 }
2401
2402 pub async fn get_commit_hashes_between_async(
2404 &self,
2405 from: &str,
2406 to: &str,
2407 ) -> Result<Vec<String>> {
2408 let repo_path = self.path.clone();
2409 let from_str = from.to_string();
2410 let to_str = to.to_string();
2411 crate::utils::async_ops::run_git_operation(move || {
2412 let repo = GitRepository::open(&repo_path)?;
2413 let commits = repo.get_commits_between(&from_str, &to_str)?;
2414 Ok(commits.into_iter().map(|c| c.id().to_string()).collect())
2415 })
2416 .await
2417 }
2418
2419 pub fn reset_branch_to_commit(&self, branch_name: &str, commit_hash: &str) -> Result<()> {
2421 info!(
2422 "Resetting branch '{}' to commit {}",
2423 branch_name,
2424 &commit_hash[..8]
2425 );
2426
2427 let target_oid = git2::Oid::from_str(commit_hash).map_err(|e| {
2429 CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
2430 })?;
2431
2432 let _target_commit = self.repo.find_commit(target_oid).map_err(|e| {
2433 CascadeError::branch(format!("Could not find commit '{commit_hash}': {e}"))
2434 })?;
2435
2436 let _branch = self
2438 .repo
2439 .find_branch(branch_name, git2::BranchType::Local)
2440 .map_err(|e| {
2441 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
2442 })?;
2443
2444 let branch_ref_name = format!("refs/heads/{branch_name}");
2446 self.repo
2447 .reference(
2448 &branch_ref_name,
2449 target_oid,
2450 true,
2451 &format!("Reset {branch_name} to {commit_hash}"),
2452 )
2453 .map_err(|e| {
2454 CascadeError::branch(format!(
2455 "Could not reset branch '{branch_name}' to commit '{commit_hash}': {e}"
2456 ))
2457 })?;
2458
2459 tracing::info!(
2460 "Successfully reset branch '{}' to commit {}",
2461 branch_name,
2462 &commit_hash[..8]
2463 );
2464 Ok(())
2465 }
2466}
2467
2468#[cfg(test)]
2469mod tests {
2470 use super::*;
2471 use std::process::Command;
2472 use tempfile::TempDir;
2473
2474 fn create_test_repo() -> (TempDir, PathBuf) {
2475 let temp_dir = TempDir::new().unwrap();
2476 let repo_path = temp_dir.path().to_path_buf();
2477
2478 Command::new("git")
2480 .args(["init"])
2481 .current_dir(&repo_path)
2482 .output()
2483 .unwrap();
2484 Command::new("git")
2485 .args(["config", "user.name", "Test"])
2486 .current_dir(&repo_path)
2487 .output()
2488 .unwrap();
2489 Command::new("git")
2490 .args(["config", "user.email", "test@test.com"])
2491 .current_dir(&repo_path)
2492 .output()
2493 .unwrap();
2494
2495 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
2497 Command::new("git")
2498 .args(["add", "."])
2499 .current_dir(&repo_path)
2500 .output()
2501 .unwrap();
2502 Command::new("git")
2503 .args(["commit", "-m", "Initial commit"])
2504 .current_dir(&repo_path)
2505 .output()
2506 .unwrap();
2507
2508 (temp_dir, repo_path)
2509 }
2510
2511 fn create_commit(repo_path: &PathBuf, message: &str, filename: &str) {
2512 let file_path = repo_path.join(filename);
2513 std::fs::write(&file_path, format!("Content for {filename}\n")).unwrap();
2514
2515 Command::new("git")
2516 .args(["add", filename])
2517 .current_dir(repo_path)
2518 .output()
2519 .unwrap();
2520 Command::new("git")
2521 .args(["commit", "-m", message])
2522 .current_dir(repo_path)
2523 .output()
2524 .unwrap();
2525 }
2526
2527 #[test]
2528 fn test_repository_info() {
2529 let (_temp_dir, repo_path) = create_test_repo();
2530 let repo = GitRepository::open(&repo_path).unwrap();
2531
2532 let info = repo.get_info().unwrap();
2533 assert!(!info.is_dirty); assert!(
2535 info.head_branch == Some("master".to_string())
2536 || info.head_branch == Some("main".to_string()),
2537 "Expected default branch to be 'master' or 'main', got {:?}",
2538 info.head_branch
2539 );
2540 assert!(info.head_commit.is_some()); assert!(info.untracked_files.is_empty()); }
2543
2544 #[test]
2545 fn test_force_push_branch_basic() {
2546 let (_temp_dir, repo_path) = create_test_repo();
2547 let repo = GitRepository::open(&repo_path).unwrap();
2548
2549 let default_branch = repo.get_current_branch().unwrap();
2551
2552 create_commit(&repo_path, "Feature commit 1", "feature1.rs");
2554 Command::new("git")
2555 .args(["checkout", "-b", "source-branch"])
2556 .current_dir(&repo_path)
2557 .output()
2558 .unwrap();
2559 create_commit(&repo_path, "Feature commit 2", "feature2.rs");
2560
2561 Command::new("git")
2563 .args(["checkout", &default_branch])
2564 .current_dir(&repo_path)
2565 .output()
2566 .unwrap();
2567 Command::new("git")
2568 .args(["checkout", "-b", "target-branch"])
2569 .current_dir(&repo_path)
2570 .output()
2571 .unwrap();
2572 create_commit(&repo_path, "Target commit", "target.rs");
2573
2574 let result = repo.force_push_branch("target-branch", "source-branch");
2576
2577 assert!(result.is_ok() || result.is_err()); }
2581
2582 #[test]
2583 fn test_force_push_branch_nonexistent_branches() {
2584 let (_temp_dir, repo_path) = create_test_repo();
2585 let repo = GitRepository::open(&repo_path).unwrap();
2586
2587 let default_branch = repo.get_current_branch().unwrap();
2589
2590 let result = repo.force_push_branch("target", "nonexistent-source");
2592 assert!(result.is_err());
2593
2594 let result = repo.force_push_branch("nonexistent-target", &default_branch);
2596 assert!(result.is_err());
2597 }
2598
2599 #[test]
2600 fn test_force_push_workflow_simulation() {
2601 let (_temp_dir, repo_path) = create_test_repo();
2602 let repo = GitRepository::open(&repo_path).unwrap();
2603
2604 Command::new("git")
2607 .args(["checkout", "-b", "feature-auth"])
2608 .current_dir(&repo_path)
2609 .output()
2610 .unwrap();
2611 create_commit(&repo_path, "Add authentication", "auth.rs");
2612
2613 Command::new("git")
2615 .args(["checkout", "-b", "feature-auth-v2"])
2616 .current_dir(&repo_path)
2617 .output()
2618 .unwrap();
2619 create_commit(&repo_path, "Fix auth validation", "auth.rs");
2620
2621 let result = repo.force_push_branch("feature-auth", "feature-auth-v2");
2623
2624 match result {
2626 Ok(_) => {
2627 Command::new("git")
2629 .args(["checkout", "feature-auth"])
2630 .current_dir(&repo_path)
2631 .output()
2632 .unwrap();
2633 let log_output = Command::new("git")
2634 .args(["log", "--oneline", "-2"])
2635 .current_dir(&repo_path)
2636 .output()
2637 .unwrap();
2638 let log_str = String::from_utf8_lossy(&log_output.stdout);
2639 assert!(
2640 log_str.contains("Fix auth validation")
2641 || log_str.contains("Add authentication")
2642 );
2643 }
2644 Err(_) => {
2645 }
2648 }
2649 }
2650
2651 #[test]
2652 fn test_branch_operations() {
2653 let (_temp_dir, repo_path) = create_test_repo();
2654 let repo = GitRepository::open(&repo_path).unwrap();
2655
2656 let current = repo.get_current_branch().unwrap();
2658 assert!(
2659 current == "master" || current == "main",
2660 "Expected default branch to be 'master' or 'main', got '{current}'"
2661 );
2662
2663 Command::new("git")
2665 .args(["checkout", "-b", "test-branch"])
2666 .current_dir(&repo_path)
2667 .output()
2668 .unwrap();
2669 let current = repo.get_current_branch().unwrap();
2670 assert_eq!(current, "test-branch");
2671 }
2672
2673 #[test]
2674 fn test_commit_operations() {
2675 let (_temp_dir, repo_path) = create_test_repo();
2676 let repo = GitRepository::open(&repo_path).unwrap();
2677
2678 let head = repo.get_head_commit().unwrap();
2680 assert_eq!(head.message().unwrap().trim(), "Initial commit");
2681
2682 let hash = head.id().to_string();
2684 let same_commit = repo.get_commit(&hash).unwrap();
2685 assert_eq!(head.id(), same_commit.id());
2686 }
2687
2688 #[test]
2689 fn test_checkout_safety_clean_repo() {
2690 let (_temp_dir, repo_path) = create_test_repo();
2691 let repo = GitRepository::open(&repo_path).unwrap();
2692
2693 create_commit(&repo_path, "Second commit", "test.txt");
2695 Command::new("git")
2696 .args(["checkout", "-b", "test-branch"])
2697 .current_dir(&repo_path)
2698 .output()
2699 .unwrap();
2700
2701 let safety_result = repo.check_checkout_safety("main");
2703 assert!(safety_result.is_ok());
2704 assert!(safety_result.unwrap().is_none()); }
2706
2707 #[test]
2708 fn test_checkout_safety_with_modified_files() {
2709 let (_temp_dir, repo_path) = create_test_repo();
2710 let repo = GitRepository::open(&repo_path).unwrap();
2711
2712 Command::new("git")
2714 .args(["checkout", "-b", "test-branch"])
2715 .current_dir(&repo_path)
2716 .output()
2717 .unwrap();
2718
2719 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2721
2722 let safety_result = repo.check_checkout_safety("main");
2724 assert!(safety_result.is_ok());
2725 let safety_info = safety_result.unwrap();
2726 assert!(safety_info.is_some());
2727
2728 let info = safety_info.unwrap();
2729 assert!(!info.modified_files.is_empty());
2730 assert!(info.modified_files.contains(&"README.md".to_string()));
2731 }
2732
2733 #[test]
2734 fn test_unsafe_checkout_methods() {
2735 let (_temp_dir, repo_path) = create_test_repo();
2736 let repo = GitRepository::open(&repo_path).unwrap();
2737
2738 create_commit(&repo_path, "Second commit", "test.txt");
2740 Command::new("git")
2741 .args(["checkout", "-b", "test-branch"])
2742 .current_dir(&repo_path)
2743 .output()
2744 .unwrap();
2745
2746 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2748
2749 let _result = repo.checkout_branch_unsafe("master");
2751 let head_commit = repo.get_head_commit().unwrap();
2756 let commit_hash = head_commit.id().to_string();
2757 let _result = repo.checkout_commit_unsafe(&commit_hash);
2758 }
2760
2761 #[test]
2762 fn test_get_modified_files() {
2763 let (_temp_dir, repo_path) = create_test_repo();
2764 let repo = GitRepository::open(&repo_path).unwrap();
2765
2766 let modified = repo.get_modified_files().unwrap();
2768 assert!(modified.is_empty());
2769
2770 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2772
2773 let modified = repo.get_modified_files().unwrap();
2775 assert_eq!(modified.len(), 1);
2776 assert!(modified.contains(&"README.md".to_string()));
2777 }
2778
2779 #[test]
2780 fn test_get_staged_files() {
2781 let (_temp_dir, repo_path) = create_test_repo();
2782 let repo = GitRepository::open(&repo_path).unwrap();
2783
2784 let staged = repo.get_staged_files().unwrap();
2786 assert!(staged.is_empty());
2787
2788 std::fs::write(repo_path.join("staged.txt"), "Staged content").unwrap();
2790 Command::new("git")
2791 .args(["add", "staged.txt"])
2792 .current_dir(&repo_path)
2793 .output()
2794 .unwrap();
2795
2796 let staged = repo.get_staged_files().unwrap();
2798 assert_eq!(staged.len(), 1);
2799 assert!(staged.contains(&"staged.txt".to_string()));
2800 }
2801
2802 #[test]
2803 fn test_create_stash_fallback() {
2804 let (_temp_dir, repo_path) = create_test_repo();
2805 let repo = GitRepository::open(&repo_path).unwrap();
2806
2807 let result = repo.create_stash("test stash");
2809
2810 match result {
2812 Ok(stash_id) => {
2813 assert!(!stash_id.is_empty());
2815 assert!(stash_id.contains("stash") || stash_id.len() >= 7); }
2817 Err(error) => {
2818 let error_msg = error.to_string();
2820 assert!(
2821 error_msg.contains("No local changes to save")
2822 || error_msg.contains("git stash push")
2823 );
2824 }
2825 }
2826 }
2827
2828 #[test]
2829 fn test_delete_branch_unsafe() {
2830 let (_temp_dir, repo_path) = create_test_repo();
2831 let repo = GitRepository::open(&repo_path).unwrap();
2832
2833 create_commit(&repo_path, "Second commit", "test.txt");
2835 Command::new("git")
2836 .args(["checkout", "-b", "test-branch"])
2837 .current_dir(&repo_path)
2838 .output()
2839 .unwrap();
2840
2841 create_commit(&repo_path, "Branch-specific commit", "branch.txt");
2843
2844 Command::new("git")
2846 .args(["checkout", "master"])
2847 .current_dir(&repo_path)
2848 .output()
2849 .unwrap();
2850
2851 let result = repo.delete_branch_unsafe("test-branch");
2854 let _ = result; }
2858
2859 #[test]
2860 fn test_force_push_unsafe() {
2861 let (_temp_dir, repo_path) = create_test_repo();
2862 let repo = GitRepository::open(&repo_path).unwrap();
2863
2864 create_commit(&repo_path, "Second commit", "test.txt");
2866 Command::new("git")
2867 .args(["checkout", "-b", "test-branch"])
2868 .current_dir(&repo_path)
2869 .output()
2870 .unwrap();
2871
2872 let _result = repo.force_push_branch_unsafe("test-branch", "test-branch");
2875 }
2877}