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 let mut callbacks = git2::RemoteCallbacks::new();
700
701 let bitbucket_credentials = self.bitbucket_credentials.clone();
703 callbacks.credentials(move |url, username_from_url, allowed_types| {
704 tracing::debug!(
705 "Authentication requested for URL: {}, username: {:?}, allowed_types: {:?}",
706 url,
707 username_from_url,
708 allowed_types
709 );
710
711 if allowed_types.contains(git2::CredentialType::SSH_KEY) {
713 if let Some(username) = username_from_url {
714 tracing::debug!("Trying SSH key authentication for user: {}", username);
715 return git2::Cred::ssh_key_from_agent(username);
716 }
717 }
718
719 if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
721 if url.contains("bitbucket") {
722 if let Some(creds) = &bitbucket_credentials {
723 if let (Some(username), Some(token)) = (&creds.username, &creds.token) {
725 tracing::debug!("Trying Bitbucket username + token authentication");
726 return git2::Cred::userpass_plaintext(username, token);
727 }
728
729 if let Some(token) = &creds.token {
731 tracing::debug!("Trying Bitbucket token-as-username authentication");
732 return git2::Cred::userpass_plaintext(token, "");
733 }
734
735 if let Some(username) = &creds.username {
737 tracing::debug!("Trying Bitbucket username authentication (will use credential helper)");
738 return git2::Cred::username(username);
739 }
740 }
741 }
742
743 tracing::debug!("Trying default credential helper for HTTPS authentication");
745 return git2::Cred::default();
746 }
747
748 tracing::debug!("Using default credential fallback");
750 git2::Cred::default()
751 });
752
753 let mut ssl_configured = false;
758
759 if let Some(ssl_config) = &self.ssl_config {
761 if ssl_config.accept_invalid_certs {
762 Output::warning(
763 "SSL certificate verification DISABLED via Cascade config - this is insecure!",
764 );
765 callbacks.certificate_check(|_cert, _host| {
766 tracing::debug!("⚠️ Accepting invalid certificate for host: {}", _host);
767 Ok(git2::CertificateCheckStatus::CertificateOk)
768 });
769 ssl_configured = true;
770 } else if let Some(ca_path) = &ssl_config.ca_bundle_path {
771 Output::info(format!(
772 "Using custom CA bundle from Cascade config: {ca_path}"
773 ));
774 callbacks.certificate_check(|_cert, host| {
775 tracing::debug!("Using custom CA bundle for host: {}", host);
776 Ok(git2::CertificateCheckStatus::CertificateOk)
777 });
778 ssl_configured = true;
779 }
780 }
781
782 if !ssl_configured {
784 if let Ok(config) = self.repo.config() {
785 let ssl_verify = config.get_bool("http.sslVerify").unwrap_or(true);
786
787 if !ssl_verify {
788 Output::warning(
789 "SSL certificate verification DISABLED via git config - this is insecure!",
790 );
791 callbacks.certificate_check(|_cert, host| {
792 tracing::debug!("⚠️ Bypassing SSL verification for host: {}", host);
793 Ok(git2::CertificateCheckStatus::CertificateOk)
794 });
795 ssl_configured = true;
796 } else if let Ok(ca_path) = config.get_string("http.sslCAInfo") {
797 Output::info(format!("Using custom CA bundle from git config: {ca_path}"));
798 callbacks.certificate_check(|_cert, host| {
799 tracing::debug!("Using git config CA bundle for host: {}", host);
800 Ok(git2::CertificateCheckStatus::CertificateOk)
801 });
802 ssl_configured = true;
803 }
804 }
805 }
806
807 if !ssl_configured {
810 tracing::debug!(
811 "Using system certificate store for SSL verification (default behavior)"
812 );
813
814 if cfg!(target_os = "macos") {
816 tracing::debug!("macOS detected - using default certificate validation");
817 } else {
820 callbacks.certificate_check(|_cert, host| {
822 tracing::debug!("System certificate validation for host: {}", host);
823 Ok(git2::CertificateCheckStatus::CertificatePassthrough)
824 });
825 }
826 }
827
828 Ok(callbacks)
829 }
830
831 fn get_index_tree(&self) -> Result<Oid> {
833 let mut index = self.repo.index().map_err(CascadeError::Git)?;
834
835 index.write_tree().map_err(CascadeError::Git)
836 }
837
838 pub fn get_status(&self) -> Result<git2::Statuses<'_>> {
840 self.repo.statuses(None).map_err(CascadeError::Git)
841 }
842
843 pub fn get_remote_url(&self, name: &str) -> Result<String> {
845 let remote = self.repo.find_remote(name).map_err(CascadeError::Git)?;
846 Ok(remote.url().unwrap_or("unknown").to_string())
847 }
848
849 pub fn cherry_pick(&self, commit_hash: &str) -> Result<String> {
851 tracing::debug!("Cherry-picking commit {}", commit_hash);
852
853 self.validate_git_user_config()?;
855
856 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
857 let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
858
859 let commit_tree = commit.tree().map_err(CascadeError::Git)?;
861
862 let parent_commit = if commit.parent_count() > 0 {
864 commit.parent(0).map_err(CascadeError::Git)?
865 } else {
866 let empty_tree_oid = self.repo.treebuilder(None)?.write()?;
868 let empty_tree = self.repo.find_tree(empty_tree_oid)?;
869 let sig = self.get_signature()?;
870 return self
871 .repo
872 .commit(
873 Some("HEAD"),
874 &sig,
875 &sig,
876 commit.message().unwrap_or("Cherry-picked commit"),
877 &empty_tree,
878 &[],
879 )
880 .map(|oid| oid.to_string())
881 .map_err(CascadeError::Git);
882 };
883
884 let parent_tree = parent_commit.tree().map_err(CascadeError::Git)?;
885
886 let head_commit = self.get_head_commit()?;
888 let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
889
890 let mut index = self
892 .repo
893 .merge_trees(&parent_tree, &head_tree, &commit_tree, None)
894 .map_err(CascadeError::Git)?;
895
896 if index.has_conflicts() {
898 return Err(CascadeError::branch(format!(
899 "Cherry-pick of {commit_hash} has conflicts that need manual resolution"
900 )));
901 }
902
903 let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
905 let merged_tree = self
906 .repo
907 .find_tree(merged_tree_oid)
908 .map_err(CascadeError::Git)?;
909
910 let signature = self.get_signature()?;
912 let message = format!("Cherry-pick: {}", commit.message().unwrap_or(""));
913
914 let new_commit_oid = self
915 .repo
916 .commit(
917 Some("HEAD"),
918 &signature,
919 &signature,
920 &message,
921 &merged_tree,
922 &[&head_commit],
923 )
924 .map_err(CascadeError::Git)?;
925
926 tracing::info!("Cherry-picked {} -> {}", commit_hash, new_commit_oid);
927 Ok(new_commit_oid.to_string())
928 }
929
930 pub fn has_conflicts(&self) -> Result<bool> {
932 let index = self.repo.index().map_err(CascadeError::Git)?;
933 Ok(index.has_conflicts())
934 }
935
936 pub fn get_conflicted_files(&self) -> Result<Vec<String>> {
938 let index = self.repo.index().map_err(CascadeError::Git)?;
939
940 let mut conflicts = Vec::new();
941
942 let conflict_iter = index.conflicts().map_err(CascadeError::Git)?;
944
945 for conflict in conflict_iter {
946 let conflict = conflict.map_err(CascadeError::Git)?;
947 if let Some(our) = conflict.our {
948 if let Ok(path) = std::str::from_utf8(&our.path) {
949 conflicts.push(path.to_string());
950 }
951 } else if let Some(their) = conflict.their {
952 if let Ok(path) = std::str::from_utf8(&their.path) {
953 conflicts.push(path.to_string());
954 }
955 }
956 }
957
958 Ok(conflicts)
959 }
960
961 pub fn fetch(&self) -> Result<()> {
963 tracing::info!("Fetching from origin");
964
965 let mut remote = self
966 .repo
967 .find_remote("origin")
968 .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
969
970 let callbacks = self.configure_remote_callbacks()?;
972
973 let mut fetch_options = git2::FetchOptions::new();
975 fetch_options.remote_callbacks(callbacks);
976
977 match remote.fetch::<&str>(&[], Some(&mut fetch_options), None) {
979 Ok(_) => {
980 tracing::debug!("Fetch completed successfully");
981 Ok(())
982 }
983 Err(e) => {
984 let error_string = e.to_string();
986 if error_string.contains("TLS stream") || error_string.contains("SSL") {
987 tracing::warn!(
988 "git2 TLS error detected: {}, falling back to git CLI for fetch operation",
989 e
990 );
991 return self.fetch_with_git_cli();
992 }
993 Err(CascadeError::Git(e))
994 }
995 }
996 }
997
998 pub fn pull(&self, branch: &str) -> Result<()> {
1000 tracing::info!("Pulling branch: {}", branch);
1001
1002 match self.fetch() {
1004 Ok(_) => {}
1005 Err(e) => {
1006 let error_string = e.to_string();
1008 if error_string.contains("TLS stream") || error_string.contains("SSL") {
1009 tracing::warn!(
1010 "git2 error detected: {}, falling back to git CLI for pull operation",
1011 e
1012 );
1013 return self.pull_with_git_cli(branch);
1014 }
1015 return Err(e);
1016 }
1017 }
1018
1019 let remote_branch_name = format!("origin/{branch}");
1021 let remote_oid = self
1022 .repo
1023 .refname_to_id(&format!("refs/remotes/{remote_branch_name}"))
1024 .map_err(|e| {
1025 CascadeError::branch(format!("Remote branch {remote_branch_name} not found: {e}"))
1026 })?;
1027
1028 let remote_commit = self
1029 .repo
1030 .find_commit(remote_oid)
1031 .map_err(CascadeError::Git)?;
1032
1033 let head_commit = self.get_head_commit()?;
1035
1036 if head_commit.id() == remote_commit.id() {
1038 tracing::debug!("Already up to date");
1039 return Ok(());
1040 }
1041
1042 let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
1044 let remote_tree = remote_commit.tree().map_err(CascadeError::Git)?;
1045
1046 let merge_base_oid = self
1048 .repo
1049 .merge_base(head_commit.id(), remote_commit.id())
1050 .map_err(CascadeError::Git)?;
1051 let merge_base_commit = self
1052 .repo
1053 .find_commit(merge_base_oid)
1054 .map_err(CascadeError::Git)?;
1055 let merge_base_tree = merge_base_commit.tree().map_err(CascadeError::Git)?;
1056
1057 let mut index = self
1059 .repo
1060 .merge_trees(&merge_base_tree, &head_tree, &remote_tree, None)
1061 .map_err(CascadeError::Git)?;
1062
1063 if index.has_conflicts() {
1064 return Err(CascadeError::branch(
1065 "Pull has conflicts that need manual resolution".to_string(),
1066 ));
1067 }
1068
1069 let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
1071 let merged_tree = self
1072 .repo
1073 .find_tree(merged_tree_oid)
1074 .map_err(CascadeError::Git)?;
1075
1076 let signature = self.get_signature()?;
1077 let message = format!("Merge branch '{branch}' from origin");
1078
1079 self.repo
1080 .commit(
1081 Some("HEAD"),
1082 &signature,
1083 &signature,
1084 &message,
1085 &merged_tree,
1086 &[&head_commit, &remote_commit],
1087 )
1088 .map_err(CascadeError::Git)?;
1089
1090 tracing::info!("Pull completed successfully");
1091 Ok(())
1092 }
1093
1094 pub fn push(&self, branch: &str) -> Result<()> {
1096 let mut remote = self
1099 .repo
1100 .find_remote("origin")
1101 .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
1102
1103 let remote_url = remote.url().unwrap_or("unknown").to_string();
1104 tracing::debug!("Remote URL: {}", remote_url);
1105
1106 let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
1107 tracing::debug!("Push refspec: {}", refspec);
1108
1109 let mut callbacks = self.configure_remote_callbacks()?;
1111
1112 callbacks.push_update_reference(|refname, status| {
1114 if let Some(msg) = status {
1115 tracing::error!("Push failed for ref {}: {}", refname, msg);
1116 return Err(git2::Error::from_str(&format!("Push failed: {msg}")));
1117 }
1118 tracing::debug!("Push succeeded for ref: {}", refname);
1119 Ok(())
1120 });
1121
1122 let mut push_options = git2::PushOptions::new();
1124 push_options.remote_callbacks(callbacks);
1125
1126 match remote.push(&[&refspec], Some(&mut push_options)) {
1128 Ok(_) => {
1129 tracing::info!("Push completed successfully for branch: {}", branch);
1130 Ok(())
1131 }
1132 Err(e) => {
1133 let error_string = e.to_string();
1135 tracing::debug!("git2 push error: {} (class: {:?})", error_string, e.class());
1136
1137 if error_string.contains("TLS stream")
1138 || error_string.contains("SSL")
1139 || e.class() == git2::ErrorClass::Ssl
1140 || error_string.contains("authentication required")
1141 || error_string.contains("no callback set")
1142 || e.class() == git2::ErrorClass::Http
1143 {
1144 return self.push_with_git_cli(branch);
1146 }
1147
1148 let error_msg = if e.to_string().contains("authentication") {
1150 format!(
1151 "Authentication failed for branch '{branch}'. Try: git push origin {branch}"
1152 )
1153 } else {
1154 format!("Failed to push branch '{branch}': {e}")
1155 };
1156
1157 tracing::error!("{}", error_msg);
1158 Err(CascadeError::branch(error_msg))
1159 }
1160 }
1161 }
1162
1163 fn push_with_git_cli(&self, branch: &str) -> Result<()> {
1166 let output = std::process::Command::new("git")
1167 .args(["push", "origin", branch])
1168 .current_dir(&self.path)
1169 .output()
1170 .map_err(|e| CascadeError::branch(format!("Failed to execute git command: {e}")))?;
1171
1172 if output.status.success() {
1173 Ok(())
1175 } else {
1176 let stderr = String::from_utf8_lossy(&output.stderr);
1177 let _stdout = String::from_utf8_lossy(&output.stdout);
1178 let error_msg = if stderr.contains("SSL_connect") || stderr.contains("SSL_ERROR") {
1180 "Network error: Unable to connect to repository (VPN may be required)".to_string()
1181 } else if stderr.contains("repository") && stderr.contains("not found") {
1182 "Repository not found - check your Bitbucket configuration".to_string()
1183 } else if stderr.contains("authentication") || stderr.contains("403") {
1184 "Authentication failed - check your credentials".to_string()
1185 } else {
1186 stderr.trim().to_string()
1188 };
1189 tracing::error!("{}", error_msg);
1190 Err(CascadeError::branch(error_msg))
1191 }
1192 }
1193
1194 fn fetch_with_git_cli(&self) -> Result<()> {
1197 tracing::info!("Using git CLI fallback for fetch operation");
1198
1199 let output = std::process::Command::new("git")
1200 .args(["fetch", "origin"])
1201 .current_dir(&self.path)
1202 .output()
1203 .map_err(|e| {
1204 CascadeError::Git(git2::Error::from_str(&format!(
1205 "Failed to execute git command: {e}"
1206 )))
1207 })?;
1208
1209 if output.status.success() {
1210 tracing::info!("✅ Git CLI fetch succeeded");
1211 Ok(())
1212 } else {
1213 let stderr = String::from_utf8_lossy(&output.stderr);
1214 let stdout = String::from_utf8_lossy(&output.stdout);
1215 let error_msg = format!(
1216 "Git CLI fetch failed: {}\nStdout: {}\nStderr: {}",
1217 output.status, stdout, stderr
1218 );
1219 tracing::error!("{}", error_msg);
1220 Err(CascadeError::Git(git2::Error::from_str(&error_msg)))
1221 }
1222 }
1223
1224 fn pull_with_git_cli(&self, branch: &str) -> Result<()> {
1227 tracing::info!("Using git CLI fallback for pull operation: {}", branch);
1228
1229 let output = std::process::Command::new("git")
1230 .args(["pull", "origin", branch])
1231 .current_dir(&self.path)
1232 .output()
1233 .map_err(|e| {
1234 CascadeError::Git(git2::Error::from_str(&format!(
1235 "Failed to execute git command: {e}"
1236 )))
1237 })?;
1238
1239 if output.status.success() {
1240 tracing::info!("✅ Git CLI pull succeeded for branch: {}", branch);
1241 Ok(())
1242 } else {
1243 let stderr = String::from_utf8_lossy(&output.stderr);
1244 let stdout = String::from_utf8_lossy(&output.stdout);
1245 let error_msg = format!(
1246 "Git CLI pull failed for branch '{}': {}\nStdout: {}\nStderr: {}",
1247 branch, output.status, stdout, stderr
1248 );
1249 tracing::error!("{}", error_msg);
1250 Err(CascadeError::Git(git2::Error::from_str(&error_msg)))
1251 }
1252 }
1253
1254 fn force_push_with_git_cli(&self, branch: &str) -> Result<()> {
1257 tracing::info!(
1258 "Using git CLI fallback for force push operation: {}",
1259 branch
1260 );
1261
1262 let output = std::process::Command::new("git")
1263 .args(["push", "--force", "origin", branch])
1264 .current_dir(&self.path)
1265 .output()
1266 .map_err(|e| CascadeError::branch(format!("Failed to execute git command: {e}")))?;
1267
1268 if output.status.success() {
1269 tracing::info!("✅ Git CLI force push succeeded for branch: {}", branch);
1270 Ok(())
1271 } else {
1272 let stderr = String::from_utf8_lossy(&output.stderr);
1273 let stdout = String::from_utf8_lossy(&output.stdout);
1274 let error_msg = format!(
1275 "Git CLI force push failed for branch '{}': {}\nStdout: {}\nStderr: {}",
1276 branch, output.status, stdout, stderr
1277 );
1278 tracing::error!("{}", error_msg);
1279 Err(CascadeError::branch(error_msg))
1280 }
1281 }
1282
1283 pub fn delete_branch(&self, name: &str) -> Result<()> {
1285 self.delete_branch_with_options(name, false)
1286 }
1287
1288 pub fn delete_branch_unsafe(&self, name: &str) -> Result<()> {
1290 self.delete_branch_with_options(name, true)
1291 }
1292
1293 fn delete_branch_with_options(&self, name: &str, force_unsafe: bool) -> Result<()> {
1295 info!("Attempting to delete branch: {}", name);
1296
1297 if !force_unsafe {
1299 let safety_result = self.check_branch_deletion_safety(name)?;
1300 if let Some(safety_info) = safety_result {
1301 self.handle_branch_deletion_confirmation(name, &safety_info)?;
1303 }
1304 }
1305
1306 let mut branch = self
1307 .repo
1308 .find_branch(name, git2::BranchType::Local)
1309 .map_err(|e| CascadeError::branch(format!("Could not find branch '{name}': {e}")))?;
1310
1311 branch
1312 .delete()
1313 .map_err(|e| CascadeError::branch(format!("Could not delete branch '{name}': {e}")))?;
1314
1315 info!("Successfully deleted branch '{}'", name);
1316 Ok(())
1317 }
1318
1319 pub fn get_commits_between(&self, from: &str, to: &str) -> Result<Vec<git2::Commit<'_>>> {
1321 let from_oid = self
1322 .repo
1323 .refname_to_id(&format!("refs/heads/{from}"))
1324 .or_else(|_| Oid::from_str(from))
1325 .map_err(|e| CascadeError::branch(format!("Invalid from reference '{from}': {e}")))?;
1326
1327 let to_oid = self
1328 .repo
1329 .refname_to_id(&format!("refs/heads/{to}"))
1330 .or_else(|_| Oid::from_str(to))
1331 .map_err(|e| CascadeError::branch(format!("Invalid to reference '{to}': {e}")))?;
1332
1333 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
1334
1335 revwalk.push(to_oid).map_err(CascadeError::Git)?;
1336 revwalk.hide(from_oid).map_err(CascadeError::Git)?;
1337
1338 let mut commits = Vec::new();
1339 for oid in revwalk {
1340 let oid = oid.map_err(CascadeError::Git)?;
1341 let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
1342 commits.push(commit);
1343 }
1344
1345 Ok(commits)
1346 }
1347
1348 pub fn force_push_branch(&self, target_branch: &str, source_branch: &str) -> Result<()> {
1351 self.force_push_branch_with_options(target_branch, source_branch, false)
1352 }
1353
1354 pub fn force_push_branch_unsafe(&self, target_branch: &str, source_branch: &str) -> Result<()> {
1356 self.force_push_branch_with_options(target_branch, source_branch, true)
1357 }
1358
1359 fn force_push_branch_with_options(
1361 &self,
1362 target_branch: &str,
1363 source_branch: &str,
1364 force_unsafe: bool,
1365 ) -> Result<()> {
1366 info!(
1367 "Force pushing {} content to {} to preserve PR history",
1368 source_branch, target_branch
1369 );
1370
1371 if !force_unsafe {
1373 let safety_result = self.check_force_push_safety_enhanced(target_branch)?;
1374 if let Some(backup_info) = safety_result {
1375 self.create_backup_branch(target_branch, &backup_info.remote_commit_id)?;
1377 info!(
1378 "✅ Created backup branch: {}",
1379 backup_info.backup_branch_name
1380 );
1381 }
1382 }
1383
1384 let source_ref = self
1386 .repo
1387 .find_reference(&format!("refs/heads/{source_branch}"))
1388 .map_err(|e| {
1389 CascadeError::config(format!("Failed to find source branch {source_branch}: {e}"))
1390 })?;
1391 let source_commit = source_ref.peel_to_commit().map_err(|e| {
1392 CascadeError::config(format!(
1393 "Failed to get commit for source branch {source_branch}: {e}"
1394 ))
1395 })?;
1396
1397 let mut target_ref = self
1399 .repo
1400 .find_reference(&format!("refs/heads/{target_branch}"))
1401 .map_err(|e| {
1402 CascadeError::config(format!("Failed to find target branch {target_branch}: {e}"))
1403 })?;
1404
1405 target_ref
1406 .set_target(source_commit.id(), "Force push from rebase")
1407 .map_err(|e| {
1408 CascadeError::config(format!(
1409 "Failed to update target branch {target_branch}: {e}"
1410 ))
1411 })?;
1412
1413 let mut remote = self
1415 .repo
1416 .find_remote("origin")
1417 .map_err(|e| CascadeError::config(format!("Failed to find origin remote: {e}")))?;
1418
1419 let refspec = format!("+refs/heads/{target_branch}:refs/heads/{target_branch}");
1420
1421 let callbacks = self.configure_remote_callbacks()?;
1423
1424 let mut push_options = git2::PushOptions::new();
1426 push_options.remote_callbacks(callbacks);
1427
1428 match remote.push(&[&refspec], Some(&mut push_options)) {
1429 Ok(_) => {}
1430 Err(e) => {
1431 let error_string = e.to_string();
1433 if error_string.contains("TLS stream") || error_string.contains("SSL") {
1434 tracing::warn!(
1435 "git2 TLS error detected: {}, falling back to git CLI for force push operation",
1436 e
1437 );
1438 return self.force_push_with_git_cli(target_branch);
1439 }
1440 return Err(CascadeError::config(format!(
1441 "Failed to force push {target_branch}: {e}"
1442 )));
1443 }
1444 }
1445
1446 info!(
1447 "✅ Successfully force pushed {} to preserve PR history",
1448 target_branch
1449 );
1450 Ok(())
1451 }
1452
1453 fn check_force_push_safety_enhanced(
1456 &self,
1457 target_branch: &str,
1458 ) -> Result<Option<ForceBackupInfo>> {
1459 match self.fetch() {
1461 Ok(_) => {}
1462 Err(e) => {
1463 warn!("Could not fetch latest changes for safety check: {}", e);
1465 }
1466 }
1467
1468 let remote_ref = format!("refs/remotes/origin/{target_branch}");
1470 let local_ref = format!("refs/heads/{target_branch}");
1471
1472 let local_commit = match self.repo.find_reference(&local_ref) {
1474 Ok(reference) => reference.peel_to_commit().ok(),
1475 Err(_) => None,
1476 };
1477
1478 let remote_commit = match self.repo.find_reference(&remote_ref) {
1479 Ok(reference) => reference.peel_to_commit().ok(),
1480 Err(_) => None,
1481 };
1482
1483 if let (Some(local), Some(remote)) = (local_commit, remote_commit) {
1485 if local.id() != remote.id() {
1486 let merge_base_oid = self
1488 .repo
1489 .merge_base(local.id(), remote.id())
1490 .map_err(|e| CascadeError::config(format!("Failed to find merge base: {e}")))?;
1491
1492 if merge_base_oid != remote.id() {
1494 let commits_to_lose = self.count_commits_between(
1495 &merge_base_oid.to_string(),
1496 &remote.id().to_string(),
1497 )?;
1498
1499 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1501 let backup_branch_name = format!("{target_branch}_backup_{timestamp}");
1502
1503 warn!(
1504 "⚠️ Force push to '{}' would overwrite {} commits on remote",
1505 target_branch, commits_to_lose
1506 );
1507
1508 if std::env::var("CI").is_ok() || std::env::var("FORCE_PUSH_NO_CONFIRM").is_ok()
1510 {
1511 info!(
1512 "Non-interactive environment detected, proceeding with backup creation"
1513 );
1514 return Ok(Some(ForceBackupInfo {
1515 backup_branch_name,
1516 remote_commit_id: remote.id().to_string(),
1517 commits_that_would_be_lost: commits_to_lose,
1518 }));
1519 }
1520
1521 println!("\n⚠️ FORCE PUSH WARNING ⚠️");
1523 println!("Force push to '{target_branch}' would overwrite {commits_to_lose} commits on remote:");
1524
1525 match self
1527 .get_commits_between(&merge_base_oid.to_string(), &remote.id().to_string())
1528 {
1529 Ok(commits) => {
1530 println!("\nCommits that would be lost:");
1531 for (i, commit) in commits.iter().take(5).enumerate() {
1532 let short_hash = &commit.id().to_string()[..8];
1533 let summary = commit.summary().unwrap_or("<no message>");
1534 println!(" {}. {} - {}", i + 1, short_hash, summary);
1535 }
1536 if commits.len() > 5 {
1537 println!(" ... and {} more commits", commits.len() - 5);
1538 }
1539 }
1540 Err(_) => {
1541 println!(" (Unable to retrieve commit details)");
1542 }
1543 }
1544
1545 println!("\nA backup branch '{backup_branch_name}' will be created before proceeding.");
1546
1547 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
1548 .with_prompt("Do you want to proceed with the force push?")
1549 .default(false)
1550 .interact()
1551 .map_err(|e| {
1552 CascadeError::config(format!("Failed to get user confirmation: {e}"))
1553 })?;
1554
1555 if !confirmed {
1556 return Err(CascadeError::config(
1557 "Force push cancelled by user. Use --force to bypass this check."
1558 .to_string(),
1559 ));
1560 }
1561
1562 return Ok(Some(ForceBackupInfo {
1563 backup_branch_name,
1564 remote_commit_id: remote.id().to_string(),
1565 commits_that_would_be_lost: commits_to_lose,
1566 }));
1567 }
1568 }
1569 }
1570
1571 Ok(None)
1572 }
1573
1574 fn create_backup_branch(&self, original_branch: &str, remote_commit_id: &str) -> Result<()> {
1576 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1577 let backup_branch_name = format!("{original_branch}_backup_{timestamp}");
1578
1579 let commit_oid = Oid::from_str(remote_commit_id).map_err(|e| {
1581 CascadeError::config(format!("Invalid commit ID {remote_commit_id}: {e}"))
1582 })?;
1583
1584 let commit = self.repo.find_commit(commit_oid).map_err(|e| {
1586 CascadeError::config(format!("Failed to find commit {remote_commit_id}: {e}"))
1587 })?;
1588
1589 self.repo
1591 .branch(&backup_branch_name, &commit, false)
1592 .map_err(|e| {
1593 CascadeError::config(format!(
1594 "Failed to create backup branch {backup_branch_name}: {e}"
1595 ))
1596 })?;
1597
1598 info!(
1599 "✅ Created backup branch '{}' pointing to {}",
1600 backup_branch_name,
1601 &remote_commit_id[..8]
1602 );
1603 Ok(())
1604 }
1605
1606 fn check_branch_deletion_safety(
1609 &self,
1610 branch_name: &str,
1611 ) -> Result<Option<BranchDeletionSafety>> {
1612 match self.fetch() {
1614 Ok(_) => {}
1615 Err(e) => {
1616 warn!(
1617 "Could not fetch latest changes for branch deletion safety check: {}",
1618 e
1619 );
1620 }
1621 }
1622
1623 let branch = self
1625 .repo
1626 .find_branch(branch_name, git2::BranchType::Local)
1627 .map_err(|e| {
1628 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
1629 })?;
1630
1631 let _branch_commit = branch.get().peel_to_commit().map_err(|e| {
1632 CascadeError::branch(format!(
1633 "Could not get commit for branch '{branch_name}': {e}"
1634 ))
1635 })?;
1636
1637 let main_branch_name = self.detect_main_branch()?;
1639
1640 let is_merged_to_main = self.is_branch_merged_to_main(branch_name, &main_branch_name)?;
1642
1643 let remote_tracking_branch = self.get_remote_tracking_branch(branch_name);
1645
1646 let mut unpushed_commits = Vec::new();
1647
1648 if let Some(ref remote_branch) = remote_tracking_branch {
1650 match self.get_commits_between(remote_branch, branch_name) {
1651 Ok(commits) => {
1652 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1653 }
1654 Err(_) => {
1655 if !is_merged_to_main {
1657 if let Ok(commits) =
1658 self.get_commits_between(&main_branch_name, branch_name)
1659 {
1660 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1661 }
1662 }
1663 }
1664 }
1665 } else if !is_merged_to_main {
1666 if let Ok(commits) = self.get_commits_between(&main_branch_name, branch_name) {
1668 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1669 }
1670 }
1671
1672 if !unpushed_commits.is_empty() || (!is_merged_to_main && remote_tracking_branch.is_none())
1674 {
1675 Ok(Some(BranchDeletionSafety {
1676 unpushed_commits,
1677 remote_tracking_branch,
1678 is_merged_to_main,
1679 main_branch_name,
1680 }))
1681 } else {
1682 Ok(None)
1683 }
1684 }
1685
1686 fn handle_branch_deletion_confirmation(
1688 &self,
1689 branch_name: &str,
1690 safety_info: &BranchDeletionSafety,
1691 ) -> Result<()> {
1692 if std::env::var("CI").is_ok() || std::env::var("BRANCH_DELETE_NO_CONFIRM").is_ok() {
1694 return Err(CascadeError::branch(
1695 format!(
1696 "Branch '{branch_name}' has {} unpushed commits and cannot be deleted in non-interactive mode. Use --force to override.",
1697 safety_info.unpushed_commits.len()
1698 )
1699 ));
1700 }
1701
1702 println!("\n⚠️ BRANCH DELETION WARNING ⚠️");
1704 println!("Branch '{branch_name}' has potential issues:");
1705
1706 if !safety_info.unpushed_commits.is_empty() {
1707 println!(
1708 "\n🔍 Unpushed commits ({} total):",
1709 safety_info.unpushed_commits.len()
1710 );
1711
1712 for (i, commit_id) in safety_info.unpushed_commits.iter().take(5).enumerate() {
1714 if let Ok(commit) = self.repo.find_commit(Oid::from_str(commit_id).unwrap()) {
1715 let short_hash = &commit_id[..8];
1716 let summary = commit.summary().unwrap_or("<no message>");
1717 println!(" {}. {} - {}", i + 1, short_hash, summary);
1718 }
1719 }
1720
1721 if safety_info.unpushed_commits.len() > 5 {
1722 println!(
1723 " ... and {} more commits",
1724 safety_info.unpushed_commits.len() - 5
1725 );
1726 }
1727 }
1728
1729 if !safety_info.is_merged_to_main {
1730 println!("\n📋 Branch status:");
1731 println!(" • Not merged to '{}'", safety_info.main_branch_name);
1732 if let Some(ref remote) = safety_info.remote_tracking_branch {
1733 println!(" • Remote tracking branch: {remote}");
1734 } else {
1735 println!(" • No remote tracking branch");
1736 }
1737 }
1738
1739 println!("\n💡 Safer alternatives:");
1740 if !safety_info.unpushed_commits.is_empty() {
1741 if let Some(ref _remote) = safety_info.remote_tracking_branch {
1742 println!(" • Push commits first: git push origin {branch_name}");
1743 } else {
1744 println!(" • Create and push to remote: git push -u origin {branch_name}");
1745 }
1746 }
1747 if !safety_info.is_merged_to_main {
1748 println!(
1749 " • Merge to {} first: git checkout {} && git merge {branch_name}",
1750 safety_info.main_branch_name, safety_info.main_branch_name
1751 );
1752 }
1753
1754 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
1755 .with_prompt("Do you want to proceed with deleting this branch?")
1756 .default(false)
1757 .interact()
1758 .map_err(|e| CascadeError::branch(format!("Failed to get user confirmation: {e}")))?;
1759
1760 if !confirmed {
1761 return Err(CascadeError::branch(
1762 "Branch deletion cancelled by user. Use --force to bypass this check.".to_string(),
1763 ));
1764 }
1765
1766 Ok(())
1767 }
1768
1769 pub fn detect_main_branch(&self) -> Result<String> {
1771 let main_candidates = ["main", "master", "develop", "trunk"];
1772
1773 for candidate in &main_candidates {
1774 if self
1775 .repo
1776 .find_branch(candidate, git2::BranchType::Local)
1777 .is_ok()
1778 {
1779 return Ok(candidate.to_string());
1780 }
1781 }
1782
1783 if let Ok(head) = self.repo.head() {
1785 if let Some(name) = head.shorthand() {
1786 return Ok(name.to_string());
1787 }
1788 }
1789
1790 Ok("main".to_string())
1792 }
1793
1794 fn is_branch_merged_to_main(&self, branch_name: &str, main_branch: &str) -> Result<bool> {
1796 match self.get_commits_between(main_branch, branch_name) {
1798 Ok(commits) => Ok(commits.is_empty()),
1799 Err(_) => {
1800 Ok(false)
1802 }
1803 }
1804 }
1805
1806 fn get_remote_tracking_branch(&self, branch_name: &str) -> Option<String> {
1808 let remote_candidates = [
1810 format!("origin/{branch_name}"),
1811 format!("remotes/origin/{branch_name}"),
1812 ];
1813
1814 for candidate in &remote_candidates {
1815 if self
1816 .repo
1817 .find_reference(&format!(
1818 "refs/remotes/{}",
1819 candidate.replace("remotes/", "")
1820 ))
1821 .is_ok()
1822 {
1823 return Some(candidate.clone());
1824 }
1825 }
1826
1827 None
1828 }
1829
1830 fn check_checkout_safety(&self, _target: &str) -> Result<Option<CheckoutSafety>> {
1832 let is_dirty = self.is_dirty()?;
1834 if !is_dirty {
1835 return Ok(None);
1837 }
1838
1839 let current_branch = self.get_current_branch().ok();
1841
1842 let modified_files = self.get_modified_files()?;
1844 let staged_files = self.get_staged_files()?;
1845 let untracked_files = self.get_untracked_files()?;
1846
1847 let has_uncommitted_changes = !modified_files.is_empty() || !staged_files.is_empty();
1848
1849 if has_uncommitted_changes || !untracked_files.is_empty() {
1850 return Ok(Some(CheckoutSafety {
1851 has_uncommitted_changes,
1852 modified_files,
1853 staged_files,
1854 untracked_files,
1855 stash_created: None,
1856 current_branch,
1857 }));
1858 }
1859
1860 Ok(None)
1861 }
1862
1863 fn handle_checkout_confirmation(
1865 &self,
1866 target: &str,
1867 safety_info: &CheckoutSafety,
1868 ) -> Result<()> {
1869 let is_ci = std::env::var("CI").is_ok();
1871 let no_confirm = std::env::var("CHECKOUT_NO_CONFIRM").is_ok();
1872 let is_non_interactive = is_ci || no_confirm;
1873
1874 if is_non_interactive {
1875 return Err(CascadeError::branch(
1876 format!(
1877 "Cannot checkout '{target}' with uncommitted changes in non-interactive mode. Commit your changes or use stash first."
1878 )
1879 ));
1880 }
1881
1882 println!("\n⚠️ CHECKOUT WARNING ⚠️");
1884 println!("You have uncommitted changes that could be lost:");
1885
1886 if !safety_info.modified_files.is_empty() {
1887 println!(
1888 "\n📝 Modified files ({}):",
1889 safety_info.modified_files.len()
1890 );
1891 for file in safety_info.modified_files.iter().take(10) {
1892 println!(" - {file}");
1893 }
1894 if safety_info.modified_files.len() > 10 {
1895 println!(" ... and {} more", safety_info.modified_files.len() - 10);
1896 }
1897 }
1898
1899 if !safety_info.staged_files.is_empty() {
1900 println!("\n📁 Staged files ({}):", safety_info.staged_files.len());
1901 for file in safety_info.staged_files.iter().take(10) {
1902 println!(" - {file}");
1903 }
1904 if safety_info.staged_files.len() > 10 {
1905 println!(" ... and {} more", safety_info.staged_files.len() - 10);
1906 }
1907 }
1908
1909 if !safety_info.untracked_files.is_empty() {
1910 println!(
1911 "\n❓ Untracked files ({}):",
1912 safety_info.untracked_files.len()
1913 );
1914 for file in safety_info.untracked_files.iter().take(5) {
1915 println!(" - {file}");
1916 }
1917 if safety_info.untracked_files.len() > 5 {
1918 println!(" ... and {} more", safety_info.untracked_files.len() - 5);
1919 }
1920 }
1921
1922 println!("\n🔄 Options:");
1923 println!("1. Stash changes and checkout (recommended)");
1924 println!("2. Force checkout (WILL LOSE UNCOMMITTED CHANGES)");
1925 println!("3. Cancel checkout");
1926
1927 let selection = Select::with_theme(&ColorfulTheme::default())
1929 .with_prompt("Choose an action")
1930 .items(&[
1931 "Stash changes and checkout (recommended)",
1932 "Force checkout (WILL LOSE UNCOMMITTED CHANGES)",
1933 "Cancel checkout",
1934 ])
1935 .default(0)
1936 .interact()
1937 .map_err(|e| CascadeError::branch(format!("Could not get user selection: {e}")))?;
1938
1939 match selection {
1940 0 => {
1941 let stash_message = format!(
1943 "Auto-stash before checkout to {} at {}",
1944 target,
1945 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
1946 );
1947
1948 match self.create_stash(&stash_message) {
1949 Ok(stash_id) => {
1950 println!("✅ Created stash: {stash_message} ({stash_id})");
1951 println!("💡 You can restore with: git stash pop");
1952 }
1953 Err(e) => {
1954 println!("❌ Failed to create stash: {e}");
1955
1956 use dialoguer::Select;
1958 let stash_failed_options = vec![
1959 "Commit staged changes and proceed",
1960 "Force checkout (WILL LOSE CHANGES)",
1961 "Cancel and handle manually",
1962 ];
1963
1964 let stash_selection = Select::with_theme(&ColorfulTheme::default())
1965 .with_prompt("Stash failed. What would you like to do?")
1966 .items(&stash_failed_options)
1967 .default(0)
1968 .interact()
1969 .map_err(|e| {
1970 CascadeError::branch(format!("Could not get user selection: {e}"))
1971 })?;
1972
1973 match stash_selection {
1974 0 => {
1975 let staged_files = self.get_staged_files()?;
1977 if !staged_files.is_empty() {
1978 println!(
1979 "📝 Committing {} staged files...",
1980 staged_files.len()
1981 );
1982 match self
1983 .commit_staged_changes("WIP: Auto-commit before checkout")
1984 {
1985 Ok(Some(commit_hash)) => {
1986 println!(
1987 "✅ Committed staged changes as {}",
1988 &commit_hash[..8]
1989 );
1990 println!("💡 You can undo with: git reset HEAD~1");
1991 }
1992 Ok(None) => {
1993 println!("ℹ️ No staged changes found to commit");
1994 }
1995 Err(commit_err) => {
1996 println!(
1997 "❌ Failed to commit staged changes: {commit_err}"
1998 );
1999 return Err(CascadeError::branch(
2000 "Could not commit staged changes".to_string(),
2001 ));
2002 }
2003 }
2004 } else {
2005 println!("ℹ️ No staged changes to commit");
2006 }
2007 }
2008 1 => {
2009 println!("⚠️ Proceeding with force checkout - uncommitted changes will be lost!");
2011 }
2012 2 => {
2013 return Err(CascadeError::branch(
2015 "Checkout cancelled. Please handle changes manually and try again.".to_string(),
2016 ));
2017 }
2018 _ => unreachable!(),
2019 }
2020 }
2021 }
2022 }
2023 1 => {
2024 println!("⚠️ Proceeding with force checkout - uncommitted changes will be lost!");
2026 }
2027 2 => {
2028 return Err(CascadeError::branch(
2030 "Checkout cancelled by user".to_string(),
2031 ));
2032 }
2033 _ => unreachable!(),
2034 }
2035
2036 Ok(())
2037 }
2038
2039 fn create_stash(&self, message: &str) -> Result<String> {
2041 tracing::info!("Creating stash: {}", message);
2042
2043 let output = std::process::Command::new("git")
2045 .args(["stash", "push", "-m", message])
2046 .current_dir(&self.path)
2047 .output()
2048 .map_err(|e| {
2049 CascadeError::branch(format!("Failed to execute git stash command: {e}"))
2050 })?;
2051
2052 if output.status.success() {
2053 let stdout = String::from_utf8_lossy(&output.stdout);
2054
2055 let stash_id = if stdout.contains("Saved working directory") {
2057 let stash_list_output = std::process::Command::new("git")
2059 .args(["stash", "list", "-n", "1", "--format=%H"])
2060 .current_dir(&self.path)
2061 .output()
2062 .map_err(|e| CascadeError::branch(format!("Failed to get stash ID: {e}")))?;
2063
2064 if stash_list_output.status.success() {
2065 String::from_utf8_lossy(&stash_list_output.stdout)
2066 .trim()
2067 .to_string()
2068 } else {
2069 "stash@{0}".to_string() }
2071 } else {
2072 "stash@{0}".to_string() };
2074
2075 tracing::info!("✅ Created stash: {} ({})", message, stash_id);
2076 Ok(stash_id)
2077 } else {
2078 let stderr = String::from_utf8_lossy(&output.stderr);
2079 let stdout = String::from_utf8_lossy(&output.stdout);
2080
2081 if stderr.contains("No local changes to save")
2083 || stdout.contains("No local changes to save")
2084 {
2085 return Err(CascadeError::branch("No local changes to save".to_string()));
2086 }
2087
2088 Err(CascadeError::branch(format!(
2089 "Failed to create stash: {}\nStderr: {}\nStdout: {}",
2090 output.status, stderr, stdout
2091 )))
2092 }
2093 }
2094
2095 fn get_modified_files(&self) -> Result<Vec<String>> {
2097 let mut opts = git2::StatusOptions::new();
2098 opts.include_untracked(false).include_ignored(false);
2099
2100 let statuses = self
2101 .repo
2102 .statuses(Some(&mut opts))
2103 .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
2104
2105 let mut modified_files = Vec::new();
2106 for status in statuses.iter() {
2107 let flags = status.status();
2108 if flags.contains(git2::Status::WT_MODIFIED) || flags.contains(git2::Status::WT_DELETED)
2109 {
2110 if let Some(path) = status.path() {
2111 modified_files.push(path.to_string());
2112 }
2113 }
2114 }
2115
2116 Ok(modified_files)
2117 }
2118
2119 pub fn get_staged_files(&self) -> Result<Vec<String>> {
2121 let mut opts = git2::StatusOptions::new();
2122 opts.include_untracked(false).include_ignored(false);
2123
2124 let statuses = self
2125 .repo
2126 .statuses(Some(&mut opts))
2127 .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
2128
2129 let mut staged_files = Vec::new();
2130 for status in statuses.iter() {
2131 let flags = status.status();
2132 if flags.contains(git2::Status::INDEX_MODIFIED)
2133 || flags.contains(git2::Status::INDEX_NEW)
2134 || flags.contains(git2::Status::INDEX_DELETED)
2135 {
2136 if let Some(path) = status.path() {
2137 staged_files.push(path.to_string());
2138 }
2139 }
2140 }
2141
2142 Ok(staged_files)
2143 }
2144
2145 fn count_commits_between(&self, from: &str, to: &str) -> Result<usize> {
2147 let commits = self.get_commits_between(from, to)?;
2148 Ok(commits.len())
2149 }
2150
2151 pub fn resolve_reference(&self, reference: &str) -> Result<git2::Commit<'_>> {
2153 if let Ok(oid) = Oid::from_str(reference) {
2155 if let Ok(commit) = self.repo.find_commit(oid) {
2156 return Ok(commit);
2157 }
2158 }
2159
2160 let obj = self.repo.revparse_single(reference).map_err(|e| {
2162 CascadeError::branch(format!("Could not resolve reference '{reference}': {e}"))
2163 })?;
2164
2165 obj.peel_to_commit().map_err(|e| {
2166 CascadeError::branch(format!(
2167 "Reference '{reference}' does not point to a commit: {e}"
2168 ))
2169 })
2170 }
2171
2172 pub fn reset_soft(&self, target_ref: &str) -> Result<()> {
2174 let target_commit = self.resolve_reference(target_ref)?;
2175
2176 self.repo
2177 .reset(target_commit.as_object(), git2::ResetType::Soft, None)
2178 .map_err(CascadeError::Git)?;
2179
2180 Ok(())
2181 }
2182
2183 pub fn find_branch_containing_commit(&self, commit_hash: &str) -> Result<String> {
2185 let oid = Oid::from_str(commit_hash).map_err(|e| {
2186 CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
2187 })?;
2188
2189 let branches = self
2191 .repo
2192 .branches(Some(git2::BranchType::Local))
2193 .map_err(CascadeError::Git)?;
2194
2195 for branch_result in branches {
2196 let (branch, _) = branch_result.map_err(CascadeError::Git)?;
2197
2198 if let Some(branch_name) = branch.name().map_err(CascadeError::Git)? {
2199 if let Ok(branch_head) = branch.get().peel_to_commit() {
2201 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
2203 revwalk.push(branch_head.id()).map_err(CascadeError::Git)?;
2204
2205 for commit_oid in revwalk {
2206 let commit_oid = commit_oid.map_err(CascadeError::Git)?;
2207 if commit_oid == oid {
2208 return Ok(branch_name.to_string());
2209 }
2210 }
2211 }
2212 }
2213 }
2214
2215 Err(CascadeError::branch(format!(
2217 "Commit {commit_hash} not found in any local branch"
2218 )))
2219 }
2220
2221 pub async fn fetch_async(&self) -> Result<()> {
2225 let repo_path = self.path.clone();
2226 crate::utils::async_ops::run_git_operation(move || {
2227 let repo = GitRepository::open(&repo_path)?;
2228 repo.fetch()
2229 })
2230 .await
2231 }
2232
2233 pub async fn pull_async(&self, branch: &str) -> Result<()> {
2235 let repo_path = self.path.clone();
2236 let branch_name = branch.to_string();
2237 crate::utils::async_ops::run_git_operation(move || {
2238 let repo = GitRepository::open(&repo_path)?;
2239 repo.pull(&branch_name)
2240 })
2241 .await
2242 }
2243
2244 pub async fn push_branch_async(&self, branch_name: &str) -> Result<()> {
2246 let repo_path = self.path.clone();
2247 let branch = branch_name.to_string();
2248 crate::utils::async_ops::run_git_operation(move || {
2249 let repo = GitRepository::open(&repo_path)?;
2250 repo.push(&branch)
2251 })
2252 .await
2253 }
2254
2255 pub async fn cherry_pick_commit_async(&self, commit_hash: &str) -> Result<String> {
2257 let repo_path = self.path.clone();
2258 let hash = commit_hash.to_string();
2259 crate::utils::async_ops::run_git_operation(move || {
2260 let repo = GitRepository::open(&repo_path)?;
2261 repo.cherry_pick(&hash)
2262 })
2263 .await
2264 }
2265
2266 pub async fn get_commit_hashes_between_async(
2268 &self,
2269 from: &str,
2270 to: &str,
2271 ) -> Result<Vec<String>> {
2272 let repo_path = self.path.clone();
2273 let from_str = from.to_string();
2274 let to_str = to.to_string();
2275 crate::utils::async_ops::run_git_operation(move || {
2276 let repo = GitRepository::open(&repo_path)?;
2277 let commits = repo.get_commits_between(&from_str, &to_str)?;
2278 Ok(commits.into_iter().map(|c| c.id().to_string()).collect())
2279 })
2280 .await
2281 }
2282
2283 pub fn reset_branch_to_commit(&self, branch_name: &str, commit_hash: &str) -> Result<()> {
2285 info!(
2286 "Resetting branch '{}' to commit {}",
2287 branch_name,
2288 &commit_hash[..8]
2289 );
2290
2291 let target_oid = git2::Oid::from_str(commit_hash).map_err(|e| {
2293 CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
2294 })?;
2295
2296 let _target_commit = self.repo.find_commit(target_oid).map_err(|e| {
2297 CascadeError::branch(format!("Could not find commit '{commit_hash}': {e}"))
2298 })?;
2299
2300 let _branch = self
2302 .repo
2303 .find_branch(branch_name, git2::BranchType::Local)
2304 .map_err(|e| {
2305 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
2306 })?;
2307
2308 let branch_ref_name = format!("refs/heads/{branch_name}");
2310 self.repo
2311 .reference(
2312 &branch_ref_name,
2313 target_oid,
2314 true,
2315 &format!("Reset {branch_name} to {commit_hash}"),
2316 )
2317 .map_err(|e| {
2318 CascadeError::branch(format!(
2319 "Could not reset branch '{branch_name}' to commit '{commit_hash}': {e}"
2320 ))
2321 })?;
2322
2323 tracing::info!(
2324 "Successfully reset branch '{}' to commit {}",
2325 branch_name,
2326 &commit_hash[..8]
2327 );
2328 Ok(())
2329 }
2330}
2331
2332#[cfg(test)]
2333mod tests {
2334 use super::*;
2335 use std::process::Command;
2336 use tempfile::TempDir;
2337
2338 fn create_test_repo() -> (TempDir, PathBuf) {
2339 let temp_dir = TempDir::new().unwrap();
2340 let repo_path = temp_dir.path().to_path_buf();
2341
2342 Command::new("git")
2344 .args(["init"])
2345 .current_dir(&repo_path)
2346 .output()
2347 .unwrap();
2348 Command::new("git")
2349 .args(["config", "user.name", "Test"])
2350 .current_dir(&repo_path)
2351 .output()
2352 .unwrap();
2353 Command::new("git")
2354 .args(["config", "user.email", "test@test.com"])
2355 .current_dir(&repo_path)
2356 .output()
2357 .unwrap();
2358
2359 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
2361 Command::new("git")
2362 .args(["add", "."])
2363 .current_dir(&repo_path)
2364 .output()
2365 .unwrap();
2366 Command::new("git")
2367 .args(["commit", "-m", "Initial commit"])
2368 .current_dir(&repo_path)
2369 .output()
2370 .unwrap();
2371
2372 (temp_dir, repo_path)
2373 }
2374
2375 fn create_commit(repo_path: &PathBuf, message: &str, filename: &str) {
2376 let file_path = repo_path.join(filename);
2377 std::fs::write(&file_path, format!("Content for {filename}\n")).unwrap();
2378
2379 Command::new("git")
2380 .args(["add", filename])
2381 .current_dir(repo_path)
2382 .output()
2383 .unwrap();
2384 Command::new("git")
2385 .args(["commit", "-m", message])
2386 .current_dir(repo_path)
2387 .output()
2388 .unwrap();
2389 }
2390
2391 #[test]
2392 fn test_repository_info() {
2393 let (_temp_dir, repo_path) = create_test_repo();
2394 let repo = GitRepository::open(&repo_path).unwrap();
2395
2396 let info = repo.get_info().unwrap();
2397 assert!(!info.is_dirty); assert!(
2399 info.head_branch == Some("master".to_string())
2400 || info.head_branch == Some("main".to_string()),
2401 "Expected default branch to be 'master' or 'main', got {:?}",
2402 info.head_branch
2403 );
2404 assert!(info.head_commit.is_some()); assert!(info.untracked_files.is_empty()); }
2407
2408 #[test]
2409 fn test_force_push_branch_basic() {
2410 let (_temp_dir, repo_path) = create_test_repo();
2411 let repo = GitRepository::open(&repo_path).unwrap();
2412
2413 let default_branch = repo.get_current_branch().unwrap();
2415
2416 create_commit(&repo_path, "Feature commit 1", "feature1.rs");
2418 Command::new("git")
2419 .args(["checkout", "-b", "source-branch"])
2420 .current_dir(&repo_path)
2421 .output()
2422 .unwrap();
2423 create_commit(&repo_path, "Feature commit 2", "feature2.rs");
2424
2425 Command::new("git")
2427 .args(["checkout", &default_branch])
2428 .current_dir(&repo_path)
2429 .output()
2430 .unwrap();
2431 Command::new("git")
2432 .args(["checkout", "-b", "target-branch"])
2433 .current_dir(&repo_path)
2434 .output()
2435 .unwrap();
2436 create_commit(&repo_path, "Target commit", "target.rs");
2437
2438 let result = repo.force_push_branch("target-branch", "source-branch");
2440
2441 assert!(result.is_ok() || result.is_err()); }
2445
2446 #[test]
2447 fn test_force_push_branch_nonexistent_branches() {
2448 let (_temp_dir, repo_path) = create_test_repo();
2449 let repo = GitRepository::open(&repo_path).unwrap();
2450
2451 let default_branch = repo.get_current_branch().unwrap();
2453
2454 let result = repo.force_push_branch("target", "nonexistent-source");
2456 assert!(result.is_err());
2457
2458 let result = repo.force_push_branch("nonexistent-target", &default_branch);
2460 assert!(result.is_err());
2461 }
2462
2463 #[test]
2464 fn test_force_push_workflow_simulation() {
2465 let (_temp_dir, repo_path) = create_test_repo();
2466 let repo = GitRepository::open(&repo_path).unwrap();
2467
2468 Command::new("git")
2471 .args(["checkout", "-b", "feature-auth"])
2472 .current_dir(&repo_path)
2473 .output()
2474 .unwrap();
2475 create_commit(&repo_path, "Add authentication", "auth.rs");
2476
2477 Command::new("git")
2479 .args(["checkout", "-b", "feature-auth-v2"])
2480 .current_dir(&repo_path)
2481 .output()
2482 .unwrap();
2483 create_commit(&repo_path, "Fix auth validation", "auth.rs");
2484
2485 let result = repo.force_push_branch("feature-auth", "feature-auth-v2");
2487
2488 match result {
2490 Ok(_) => {
2491 Command::new("git")
2493 .args(["checkout", "feature-auth"])
2494 .current_dir(&repo_path)
2495 .output()
2496 .unwrap();
2497 let log_output = Command::new("git")
2498 .args(["log", "--oneline", "-2"])
2499 .current_dir(&repo_path)
2500 .output()
2501 .unwrap();
2502 let log_str = String::from_utf8_lossy(&log_output.stdout);
2503 assert!(
2504 log_str.contains("Fix auth validation")
2505 || log_str.contains("Add authentication")
2506 );
2507 }
2508 Err(_) => {
2509 }
2512 }
2513 }
2514
2515 #[test]
2516 fn test_branch_operations() {
2517 let (_temp_dir, repo_path) = create_test_repo();
2518 let repo = GitRepository::open(&repo_path).unwrap();
2519
2520 let current = repo.get_current_branch().unwrap();
2522 assert!(
2523 current == "master" || current == "main",
2524 "Expected default branch to be 'master' or 'main', got '{current}'"
2525 );
2526
2527 Command::new("git")
2529 .args(["checkout", "-b", "test-branch"])
2530 .current_dir(&repo_path)
2531 .output()
2532 .unwrap();
2533 let current = repo.get_current_branch().unwrap();
2534 assert_eq!(current, "test-branch");
2535 }
2536
2537 #[test]
2538 fn test_commit_operations() {
2539 let (_temp_dir, repo_path) = create_test_repo();
2540 let repo = GitRepository::open(&repo_path).unwrap();
2541
2542 let head = repo.get_head_commit().unwrap();
2544 assert_eq!(head.message().unwrap().trim(), "Initial commit");
2545
2546 let hash = head.id().to_string();
2548 let same_commit = repo.get_commit(&hash).unwrap();
2549 assert_eq!(head.id(), same_commit.id());
2550 }
2551
2552 #[test]
2553 fn test_checkout_safety_clean_repo() {
2554 let (_temp_dir, repo_path) = create_test_repo();
2555 let repo = GitRepository::open(&repo_path).unwrap();
2556
2557 create_commit(&repo_path, "Second commit", "test.txt");
2559 Command::new("git")
2560 .args(["checkout", "-b", "test-branch"])
2561 .current_dir(&repo_path)
2562 .output()
2563 .unwrap();
2564
2565 let safety_result = repo.check_checkout_safety("main");
2567 assert!(safety_result.is_ok());
2568 assert!(safety_result.unwrap().is_none()); }
2570
2571 #[test]
2572 fn test_checkout_safety_with_modified_files() {
2573 let (_temp_dir, repo_path) = create_test_repo();
2574 let repo = GitRepository::open(&repo_path).unwrap();
2575
2576 Command::new("git")
2578 .args(["checkout", "-b", "test-branch"])
2579 .current_dir(&repo_path)
2580 .output()
2581 .unwrap();
2582
2583 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2585
2586 let safety_result = repo.check_checkout_safety("main");
2588 assert!(safety_result.is_ok());
2589 let safety_info = safety_result.unwrap();
2590 assert!(safety_info.is_some());
2591
2592 let info = safety_info.unwrap();
2593 assert!(!info.modified_files.is_empty());
2594 assert!(info.modified_files.contains(&"README.md".to_string()));
2595 }
2596
2597 #[test]
2598 fn test_unsafe_checkout_methods() {
2599 let (_temp_dir, repo_path) = create_test_repo();
2600 let repo = GitRepository::open(&repo_path).unwrap();
2601
2602 create_commit(&repo_path, "Second commit", "test.txt");
2604 Command::new("git")
2605 .args(["checkout", "-b", "test-branch"])
2606 .current_dir(&repo_path)
2607 .output()
2608 .unwrap();
2609
2610 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2612
2613 let _result = repo.checkout_branch_unsafe("master");
2615 let head_commit = repo.get_head_commit().unwrap();
2620 let commit_hash = head_commit.id().to_string();
2621 let _result = repo.checkout_commit_unsafe(&commit_hash);
2622 }
2624
2625 #[test]
2626 fn test_get_modified_files() {
2627 let (_temp_dir, repo_path) = create_test_repo();
2628 let repo = GitRepository::open(&repo_path).unwrap();
2629
2630 let modified = repo.get_modified_files().unwrap();
2632 assert!(modified.is_empty());
2633
2634 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2636
2637 let modified = repo.get_modified_files().unwrap();
2639 assert_eq!(modified.len(), 1);
2640 assert!(modified.contains(&"README.md".to_string()));
2641 }
2642
2643 #[test]
2644 fn test_get_staged_files() {
2645 let (_temp_dir, repo_path) = create_test_repo();
2646 let repo = GitRepository::open(&repo_path).unwrap();
2647
2648 let staged = repo.get_staged_files().unwrap();
2650 assert!(staged.is_empty());
2651
2652 std::fs::write(repo_path.join("staged.txt"), "Staged content").unwrap();
2654 Command::new("git")
2655 .args(["add", "staged.txt"])
2656 .current_dir(&repo_path)
2657 .output()
2658 .unwrap();
2659
2660 let staged = repo.get_staged_files().unwrap();
2662 assert_eq!(staged.len(), 1);
2663 assert!(staged.contains(&"staged.txt".to_string()));
2664 }
2665
2666 #[test]
2667 fn test_create_stash_fallback() {
2668 let (_temp_dir, repo_path) = create_test_repo();
2669 let repo = GitRepository::open(&repo_path).unwrap();
2670
2671 let result = repo.create_stash("test stash");
2673
2674 match result {
2676 Ok(stash_id) => {
2677 assert!(!stash_id.is_empty());
2679 assert!(stash_id.contains("stash") || stash_id.len() >= 7); }
2681 Err(error) => {
2682 let error_msg = error.to_string();
2684 assert!(
2685 error_msg.contains("No local changes to save")
2686 || error_msg.contains("git stash push")
2687 );
2688 }
2689 }
2690 }
2691
2692 #[test]
2693 fn test_delete_branch_unsafe() {
2694 let (_temp_dir, repo_path) = create_test_repo();
2695 let repo = GitRepository::open(&repo_path).unwrap();
2696
2697 create_commit(&repo_path, "Second commit", "test.txt");
2699 Command::new("git")
2700 .args(["checkout", "-b", "test-branch"])
2701 .current_dir(&repo_path)
2702 .output()
2703 .unwrap();
2704
2705 create_commit(&repo_path, "Branch-specific commit", "branch.txt");
2707
2708 Command::new("git")
2710 .args(["checkout", "master"])
2711 .current_dir(&repo_path)
2712 .output()
2713 .unwrap();
2714
2715 let result = repo.delete_branch_unsafe("test-branch");
2718 let _ = result; }
2722
2723 #[test]
2724 fn test_force_push_unsafe() {
2725 let (_temp_dir, repo_path) = create_test_repo();
2726 let repo = GitRepository::open(&repo_path).unwrap();
2727
2728 create_commit(&repo_path, "Second commit", "test.txt");
2730 Command::new("git")
2731 .args(["checkout", "-b", "test-branch"])
2732 .current_dir(&repo_path)
2733 .output()
2734 .unwrap();
2735
2736 let _result = repo.force_push_branch_unsafe("test-branch", "test-branch");
2739 }
2741}