1use crate::cli::output::Output;
2use crate::errors::{CascadeError, Result};
3use chrono;
4use dialoguer::{theme::ColorfulTheme, Confirm};
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 let signature = self.get_signature()?;
470 let tree_id = self.get_index_tree()?;
471 let tree = self.repo.find_tree(tree_id).map_err(CascadeError::Git)?;
472
473 let head = self.repo.head().map_err(CascadeError::Git)?;
475 let parent_commit = head.peel_to_commit().map_err(CascadeError::Git)?;
476
477 let commit_id = self
478 .repo
479 .commit(
480 Some("HEAD"),
481 &signature,
482 &signature,
483 message,
484 &tree,
485 &[&parent_commit],
486 )
487 .map_err(CascadeError::Git)?;
488
489 Output::success(format!("Created commit: {commit_id} - {message}"));
490 Ok(commit_id.to_string())
491 }
492
493 pub fn stage_all(&self) -> Result<()> {
495 let mut index = self.repo.index().map_err(CascadeError::Git)?;
496
497 index
498 .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
499 .map_err(CascadeError::Git)?;
500
501 index.write().map_err(CascadeError::Git)?;
502
503 tracing::debug!("Staged all changes");
504 Ok(())
505 }
506
507 pub fn stage_files(&self, file_paths: &[&str]) -> Result<()> {
509 if file_paths.is_empty() {
510 tracing::debug!("No files to stage");
511 return Ok(());
512 }
513
514 let mut index = self.repo.index().map_err(CascadeError::Git)?;
515
516 for file_path in file_paths {
517 index
518 .add_path(std::path::Path::new(file_path))
519 .map_err(CascadeError::Git)?;
520 }
521
522 index.write().map_err(CascadeError::Git)?;
523
524 tracing::debug!(
525 "Staged {} specific files: {:?}",
526 file_paths.len(),
527 file_paths
528 );
529 Ok(())
530 }
531
532 pub fn stage_conflict_resolved_files(&self) -> Result<()> {
534 let conflicted_files = self.get_conflicted_files()?;
535 if conflicted_files.is_empty() {
536 tracing::debug!("No conflicted files to stage");
537 return Ok(());
538 }
539
540 let file_paths: Vec<&str> = conflicted_files.iter().map(|s| s.as_str()).collect();
541 self.stage_files(&file_paths)?;
542
543 tracing::debug!("Staged {} conflict-resolved files", conflicted_files.len());
544 Ok(())
545 }
546
547 pub fn path(&self) -> &Path {
549 &self.path
550 }
551
552 pub fn commit_exists(&self, commit_hash: &str) -> Result<bool> {
554 match Oid::from_str(commit_hash) {
555 Ok(oid) => match self.repo.find_commit(oid) {
556 Ok(_) => Ok(true),
557 Err(_) => Ok(false),
558 },
559 Err(_) => Ok(false),
560 }
561 }
562
563 pub fn get_head_commit(&self) -> Result<git2::Commit<'_>> {
565 let head = self
566 .repo
567 .head()
568 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
569 head.peel_to_commit()
570 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))
571 }
572
573 pub fn get_commit(&self, commit_hash: &str) -> Result<git2::Commit<'_>> {
575 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
576
577 self.repo.find_commit(oid).map_err(CascadeError::Git)
578 }
579
580 pub fn get_branch_head(&self, branch_name: &str) -> Result<String> {
582 let branch = self
583 .repo
584 .find_branch(branch_name, git2::BranchType::Local)
585 .map_err(|e| {
586 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
587 })?;
588
589 let commit = branch.get().peel_to_commit().map_err(|e| {
590 CascadeError::branch(format!(
591 "Could not get commit for branch '{branch_name}': {e}"
592 ))
593 })?;
594
595 Ok(commit.id().to_string())
596 }
597
598 fn get_signature(&self) -> Result<Signature<'_>> {
600 if let Ok(config) = self.repo.config() {
602 if let (Ok(name), Ok(email)) = (
603 config.get_string("user.name"),
604 config.get_string("user.email"),
605 ) {
606 return Signature::now(&name, &email).map_err(CascadeError::Git);
607 }
608 }
609
610 Signature::now("Cascade CLI", "cascade@example.com").map_err(CascadeError::Git)
612 }
613
614 fn configure_remote_callbacks(&self) -> Result<git2::RemoteCallbacks<'_>> {
617 let mut callbacks = git2::RemoteCallbacks::new();
618
619 let bitbucket_credentials = self.bitbucket_credentials.clone();
621 callbacks.credentials(move |url, username_from_url, allowed_types| {
622 tracing::debug!(
623 "Authentication requested for URL: {}, username: {:?}, allowed_types: {:?}",
624 url,
625 username_from_url,
626 allowed_types
627 );
628
629 if allowed_types.contains(git2::CredentialType::SSH_KEY) {
631 if let Some(username) = username_from_url {
632 tracing::debug!("Trying SSH key authentication for user: {}", username);
633 return git2::Cred::ssh_key_from_agent(username);
634 }
635 }
636
637 if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
639 if url.contains("bitbucket") {
640 if let Some(creds) = &bitbucket_credentials {
641 if let (Some(username), Some(token)) = (&creds.username, &creds.token) {
643 tracing::debug!("Trying Bitbucket username + token authentication");
644 return git2::Cred::userpass_plaintext(username, token);
645 }
646
647 if let Some(token) = &creds.token {
649 tracing::debug!("Trying Bitbucket token-as-username authentication");
650 return git2::Cred::userpass_plaintext(token, "");
651 }
652
653 if let Some(username) = &creds.username {
655 tracing::debug!("Trying Bitbucket username authentication (will use credential helper)");
656 return git2::Cred::username(username);
657 }
658 }
659 }
660
661 tracing::debug!("Trying default credential helper for HTTPS authentication");
663 return git2::Cred::default();
664 }
665
666 tracing::debug!("Using default credential fallback");
668 git2::Cred::default()
669 });
670
671 let mut ssl_configured = false;
676
677 if let Some(ssl_config) = &self.ssl_config {
679 if ssl_config.accept_invalid_certs {
680 Output::warning(
681 "SSL certificate verification DISABLED via Cascade config - this is insecure!",
682 );
683 callbacks.certificate_check(|_cert, _host| {
684 tracing::debug!("⚠️ Accepting invalid certificate for host: {}", _host);
685 Ok(git2::CertificateCheckStatus::CertificateOk)
686 });
687 ssl_configured = true;
688 } else if let Some(ca_path) = &ssl_config.ca_bundle_path {
689 Output::info(format!(
690 "Using custom CA bundle from Cascade config: {ca_path}"
691 ));
692 callbacks.certificate_check(|_cert, host| {
693 tracing::debug!("Using custom CA bundle for host: {}", host);
694 Ok(git2::CertificateCheckStatus::CertificateOk)
695 });
696 ssl_configured = true;
697 }
698 }
699
700 if !ssl_configured {
702 if let Ok(config) = self.repo.config() {
703 let ssl_verify = config.get_bool("http.sslVerify").unwrap_or(true);
704
705 if !ssl_verify {
706 Output::warning(
707 "SSL certificate verification DISABLED via git config - this is insecure!",
708 );
709 callbacks.certificate_check(|_cert, host| {
710 tracing::debug!("⚠️ Bypassing SSL verification for host: {}", host);
711 Ok(git2::CertificateCheckStatus::CertificateOk)
712 });
713 ssl_configured = true;
714 } else if let Ok(ca_path) = config.get_string("http.sslCAInfo") {
715 Output::info(format!("Using custom CA bundle from git config: {ca_path}"));
716 callbacks.certificate_check(|_cert, host| {
717 tracing::debug!("Using git config CA bundle for host: {}", host);
718 Ok(git2::CertificateCheckStatus::CertificateOk)
719 });
720 ssl_configured = true;
721 }
722 }
723 }
724
725 if !ssl_configured {
728 tracing::debug!(
729 "Using system certificate store for SSL verification (default behavior)"
730 );
731
732 if cfg!(target_os = "macos") {
734 tracing::debug!("macOS detected - using default certificate validation");
735 } else {
738 callbacks.certificate_check(|_cert, host| {
740 tracing::debug!("System certificate validation for host: {}", host);
741 Ok(git2::CertificateCheckStatus::CertificatePassthrough)
742 });
743 }
744 }
745
746 Ok(callbacks)
747 }
748
749 fn get_index_tree(&self) -> Result<Oid> {
751 let mut index = self.repo.index().map_err(CascadeError::Git)?;
752
753 index.write_tree().map_err(CascadeError::Git)
754 }
755
756 pub fn get_status(&self) -> Result<git2::Statuses<'_>> {
758 self.repo.statuses(None).map_err(CascadeError::Git)
759 }
760
761 pub fn get_remote_url(&self, name: &str) -> Result<String> {
763 let remote = self.repo.find_remote(name).map_err(CascadeError::Git)?;
764 Ok(remote.url().unwrap_or("unknown").to_string())
765 }
766
767 pub fn diagnose_git2_support(&self) -> Result<()> {
770 let version = git2::Version::get();
771
772 println!("🔍 Git2 Feature Support Diagnosis:");
773 println!(" HTTPS/TLS support: {}", version.https());
774 println!(" SSH support: {}", version.ssh());
775
776 if !version.https() {
777 println!("❌ TLS streams NOT available - this explains TLS connection failures!");
778 println!(" Solution: Add 'https' feature to git2 dependency in Cargo.toml");
779 println!(" Current: git2 = {{ version = \"0.20.2\", default-features = false, features = [\"vendored-libgit2\"] }}");
780 println!(" Fixed: git2 = {{ version = \"0.20.2\", features = [\"vendored-libgit2\", \"https\", \"ssh\"] }}");
781 } else {
782 println!("✅ TLS streams available");
783 }
784
785 if !version.ssh() {
786 println!("❌ SSH support NOT available");
787 println!(" Add 'ssh' feature to git2 dependency");
788 } else {
789 println!("✅ SSH support available");
790 }
791
792 println!("\n📋 Additional git2 build information:");
794 let libgit2_version = version.libgit2_version();
795 println!(
796 " libgit2 version: {}.{}.{}",
797 libgit2_version.0, libgit2_version.1, libgit2_version.2
798 );
799
800 println!("\n💡 Recommendation:");
801 if !version.https() || !version.ssh() {
802 println!(" Your git2 is built without TLS/SSH support, causing fallback to git CLI.");
803 println!(" Enable the missing features in Cargo.toml for better performance and reliability.");
804 } else {
805 println!(
806 " git2 has full TLS/SSH support. Network issues may be configuration-related."
807 );
808 }
809
810 Ok(())
811 }
812
813 pub fn cherry_pick(&self, commit_hash: &str) -> Result<String> {
815 tracing::debug!("Cherry-picking commit {}", commit_hash);
816
817 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
818 let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
819
820 let commit_tree = commit.tree().map_err(CascadeError::Git)?;
822
823 let parent_commit = if commit.parent_count() > 0 {
825 commit.parent(0).map_err(CascadeError::Git)?
826 } else {
827 let empty_tree_oid = self.repo.treebuilder(None)?.write()?;
829 let empty_tree = self.repo.find_tree(empty_tree_oid)?;
830 let sig = self.get_signature()?;
831 return self
832 .repo
833 .commit(
834 Some("HEAD"),
835 &sig,
836 &sig,
837 commit.message().unwrap_or("Cherry-picked commit"),
838 &empty_tree,
839 &[],
840 )
841 .map(|oid| oid.to_string())
842 .map_err(CascadeError::Git);
843 };
844
845 let parent_tree = parent_commit.tree().map_err(CascadeError::Git)?;
846
847 let head_commit = self.get_head_commit()?;
849 let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
850
851 let mut index = self
853 .repo
854 .merge_trees(&parent_tree, &head_tree, &commit_tree, None)
855 .map_err(CascadeError::Git)?;
856
857 if index.has_conflicts() {
859 return Err(CascadeError::branch(format!(
860 "Cherry-pick of {commit_hash} has conflicts that need manual resolution"
861 )));
862 }
863
864 let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
866 let merged_tree = self
867 .repo
868 .find_tree(merged_tree_oid)
869 .map_err(CascadeError::Git)?;
870
871 let signature = self.get_signature()?;
873 let message = format!("Cherry-pick: {}", commit.message().unwrap_or(""));
874
875 let new_commit_oid = self
876 .repo
877 .commit(
878 Some("HEAD"),
879 &signature,
880 &signature,
881 &message,
882 &merged_tree,
883 &[&head_commit],
884 )
885 .map_err(CascadeError::Git)?;
886
887 tracing::info!("Cherry-picked {} -> {}", commit_hash, new_commit_oid);
888 Ok(new_commit_oid.to_string())
889 }
890
891 pub fn has_conflicts(&self) -> Result<bool> {
893 let index = self.repo.index().map_err(CascadeError::Git)?;
894 Ok(index.has_conflicts())
895 }
896
897 pub fn get_conflicted_files(&self) -> Result<Vec<String>> {
899 let index = self.repo.index().map_err(CascadeError::Git)?;
900
901 let mut conflicts = Vec::new();
902
903 let conflict_iter = index.conflicts().map_err(CascadeError::Git)?;
905
906 for conflict in conflict_iter {
907 let conflict = conflict.map_err(CascadeError::Git)?;
908 if let Some(our) = conflict.our {
909 if let Ok(path) = std::str::from_utf8(&our.path) {
910 conflicts.push(path.to_string());
911 }
912 } else if let Some(their) = conflict.their {
913 if let Ok(path) = std::str::from_utf8(&their.path) {
914 conflicts.push(path.to_string());
915 }
916 }
917 }
918
919 Ok(conflicts)
920 }
921
922 pub fn fetch(&self) -> Result<()> {
924 tracing::info!("Fetching from origin");
925
926 let mut remote = self
927 .repo
928 .find_remote("origin")
929 .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
930
931 let callbacks = self.configure_remote_callbacks()?;
933
934 let mut fetch_options = git2::FetchOptions::new();
936 fetch_options.remote_callbacks(callbacks);
937
938 match remote.fetch::<&str>(&[], Some(&mut fetch_options), None) {
940 Ok(_) => {
941 tracing::debug!("Fetch completed successfully");
942 Ok(())
943 }
944 Err(e) => {
945 let error_string = e.to_string();
947 if error_string.contains("TLS stream") || error_string.contains("SSL") {
948 tracing::warn!(
949 "git2 TLS error detected: {}, falling back to git CLI for fetch operation",
950 e
951 );
952 return self.fetch_with_git_cli();
953 }
954 Err(CascadeError::Git(e))
955 }
956 }
957 }
958
959 pub fn pull(&self, branch: &str) -> Result<()> {
961 tracing::info!("Pulling branch: {}", branch);
962
963 match self.fetch() {
965 Ok(_) => {}
966 Err(e) => {
967 let error_string = e.to_string();
969 if error_string.contains("TLS stream") || error_string.contains("SSL") {
970 tracing::warn!(
971 "git2 TLS error detected: {}, falling back to git CLI for pull operation",
972 e
973 );
974 return self.pull_with_git_cli(branch);
975 }
976 return Err(e);
977 }
978 }
979
980 let remote_branch_name = format!("origin/{branch}");
982 let remote_oid = self
983 .repo
984 .refname_to_id(&format!("refs/remotes/{remote_branch_name}"))
985 .map_err(|e| {
986 CascadeError::branch(format!("Remote branch {remote_branch_name} not found: {e}"))
987 })?;
988
989 let remote_commit = self
990 .repo
991 .find_commit(remote_oid)
992 .map_err(CascadeError::Git)?;
993
994 let head_commit = self.get_head_commit()?;
996
997 if head_commit.id() == remote_commit.id() {
999 tracing::debug!("Already up to date");
1000 return Ok(());
1001 }
1002
1003 let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
1005 let remote_tree = remote_commit.tree().map_err(CascadeError::Git)?;
1006
1007 let merge_base_oid = self
1009 .repo
1010 .merge_base(head_commit.id(), remote_commit.id())
1011 .map_err(CascadeError::Git)?;
1012 let merge_base_commit = self
1013 .repo
1014 .find_commit(merge_base_oid)
1015 .map_err(CascadeError::Git)?;
1016 let merge_base_tree = merge_base_commit.tree().map_err(CascadeError::Git)?;
1017
1018 let mut index = self
1020 .repo
1021 .merge_trees(&merge_base_tree, &head_tree, &remote_tree, None)
1022 .map_err(CascadeError::Git)?;
1023
1024 if index.has_conflicts() {
1025 return Err(CascadeError::branch(
1026 "Pull has conflicts that need manual resolution".to_string(),
1027 ));
1028 }
1029
1030 let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
1032 let merged_tree = self
1033 .repo
1034 .find_tree(merged_tree_oid)
1035 .map_err(CascadeError::Git)?;
1036
1037 let signature = self.get_signature()?;
1038 let message = format!("Merge branch '{branch}' from origin");
1039
1040 self.repo
1041 .commit(
1042 Some("HEAD"),
1043 &signature,
1044 &signature,
1045 &message,
1046 &merged_tree,
1047 &[&head_commit, &remote_commit],
1048 )
1049 .map_err(CascadeError::Git)?;
1050
1051 tracing::info!("Pull completed successfully");
1052 Ok(())
1053 }
1054
1055 pub fn push(&self, branch: &str) -> Result<()> {
1057 let mut remote = self
1060 .repo
1061 .find_remote("origin")
1062 .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
1063
1064 let remote_url = remote.url().unwrap_or("unknown").to_string();
1065 tracing::debug!("Remote URL: {}", remote_url);
1066
1067 let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
1068 tracing::debug!("Push refspec: {}", refspec);
1069
1070 let mut callbacks = self.configure_remote_callbacks()?;
1072
1073 callbacks.push_update_reference(|refname, status| {
1075 if let Some(msg) = status {
1076 tracing::error!("Push failed for ref {}: {}", refname, msg);
1077 return Err(git2::Error::from_str(&format!("Push failed: {msg}")));
1078 }
1079 tracing::debug!("Push succeeded for ref: {}", refname);
1080 Ok(())
1081 });
1082
1083 let mut push_options = git2::PushOptions::new();
1085 push_options.remote_callbacks(callbacks);
1086
1087 match remote.push(&[&refspec], Some(&mut push_options)) {
1089 Ok(_) => {
1090 tracing::info!("Push completed successfully for branch: {}", branch);
1091 Ok(())
1092 }
1093 Err(e) => {
1094 let error_string = e.to_string();
1096 tracing::debug!("git2 push error: {} (class: {:?})", error_string, e.class());
1097
1098 if error_string.contains("TLS stream")
1099 || error_string.contains("SSL")
1100 || e.class() == git2::ErrorClass::Ssl
1101 || error_string.contains("authentication required")
1102 || error_string.contains("no callback set")
1103 || e.class() == git2::ErrorClass::Http
1104 {
1105 return self.push_with_git_cli(branch);
1107 }
1108
1109 let error_msg = if e.to_string().contains("authentication") {
1111 format!(
1112 "Authentication failed for branch '{branch}'. Try: git push origin {branch}"
1113 )
1114 } else {
1115 format!("Failed to push branch '{branch}': {e}")
1116 };
1117
1118 tracing::error!("{}", error_msg);
1119 Err(CascadeError::branch(error_msg))
1120 }
1121 }
1122 }
1123
1124 fn push_with_git_cli(&self, branch: &str) -> Result<()> {
1127 let output = std::process::Command::new("git")
1128 .args(["push", "origin", branch])
1129 .current_dir(&self.path)
1130 .output()
1131 .map_err(|e| CascadeError::branch(format!("Failed to execute git command: {e}")))?;
1132
1133 if output.status.success() {
1134 Ok(())
1136 } else {
1137 let stderr = String::from_utf8_lossy(&output.stderr);
1138 let _stdout = String::from_utf8_lossy(&output.stdout);
1139 let error_msg = if stderr.contains("SSL_connect") || stderr.contains("SSL_ERROR") {
1141 "Network error: Unable to connect to repository (VPN may be required)".to_string()
1142 } else if stderr.contains("repository") && stderr.contains("not found") {
1143 "Repository not found - check your Bitbucket configuration".to_string()
1144 } else if stderr.contains("authentication") || stderr.contains("403") {
1145 "Authentication failed - check your credentials".to_string()
1146 } else {
1147 stderr.trim().to_string()
1149 };
1150 tracing::error!("{}", error_msg);
1151 Err(CascadeError::branch(error_msg))
1152 }
1153 }
1154
1155 fn fetch_with_git_cli(&self) -> Result<()> {
1158 tracing::info!("Using git CLI fallback for fetch operation");
1159
1160 let output = std::process::Command::new("git")
1161 .args(["fetch", "origin"])
1162 .current_dir(&self.path)
1163 .output()
1164 .map_err(|e| {
1165 CascadeError::Git(git2::Error::from_str(&format!(
1166 "Failed to execute git command: {e}"
1167 )))
1168 })?;
1169
1170 if output.status.success() {
1171 tracing::info!("✅ Git CLI fetch succeeded");
1172 Ok(())
1173 } else {
1174 let stderr = String::from_utf8_lossy(&output.stderr);
1175 let stdout = String::from_utf8_lossy(&output.stdout);
1176 let error_msg = format!(
1177 "Git CLI fetch failed: {}\nStdout: {}\nStderr: {}",
1178 output.status, stdout, stderr
1179 );
1180 tracing::error!("{}", error_msg);
1181 Err(CascadeError::Git(git2::Error::from_str(&error_msg)))
1182 }
1183 }
1184
1185 fn pull_with_git_cli(&self, branch: &str) -> Result<()> {
1188 tracing::info!("Using git CLI fallback for pull operation: {}", branch);
1189
1190 let output = std::process::Command::new("git")
1191 .args(["pull", "origin", branch])
1192 .current_dir(&self.path)
1193 .output()
1194 .map_err(|e| {
1195 CascadeError::Git(git2::Error::from_str(&format!(
1196 "Failed to execute git command: {e}"
1197 )))
1198 })?;
1199
1200 if output.status.success() {
1201 tracing::info!("✅ Git CLI pull succeeded for branch: {}", branch);
1202 Ok(())
1203 } else {
1204 let stderr = String::from_utf8_lossy(&output.stderr);
1205 let stdout = String::from_utf8_lossy(&output.stdout);
1206 let error_msg = format!(
1207 "Git CLI pull failed for branch '{}': {}\nStdout: {}\nStderr: {}",
1208 branch, output.status, stdout, stderr
1209 );
1210 tracing::error!("{}", error_msg);
1211 Err(CascadeError::Git(git2::Error::from_str(&error_msg)))
1212 }
1213 }
1214
1215 fn force_push_with_git_cli(&self, branch: &str) -> Result<()> {
1218 tracing::info!(
1219 "Using git CLI fallback for force push operation: {}",
1220 branch
1221 );
1222
1223 let output = std::process::Command::new("git")
1224 .args(["push", "--force", "origin", branch])
1225 .current_dir(&self.path)
1226 .output()
1227 .map_err(|e| CascadeError::branch(format!("Failed to execute git command: {e}")))?;
1228
1229 if output.status.success() {
1230 tracing::info!("✅ Git CLI force push succeeded for branch: {}", branch);
1231 Ok(())
1232 } else {
1233 let stderr = String::from_utf8_lossy(&output.stderr);
1234 let stdout = String::from_utf8_lossy(&output.stdout);
1235 let error_msg = format!(
1236 "Git CLI force push failed for branch '{}': {}\nStdout: {}\nStderr: {}",
1237 branch, output.status, stdout, stderr
1238 );
1239 tracing::error!("{}", error_msg);
1240 Err(CascadeError::branch(error_msg))
1241 }
1242 }
1243
1244 pub fn delete_branch(&self, name: &str) -> Result<()> {
1246 self.delete_branch_with_options(name, false)
1247 }
1248
1249 pub fn delete_branch_unsafe(&self, name: &str) -> Result<()> {
1251 self.delete_branch_with_options(name, true)
1252 }
1253
1254 fn delete_branch_with_options(&self, name: &str, force_unsafe: bool) -> Result<()> {
1256 info!("Attempting to delete branch: {}", name);
1257
1258 if !force_unsafe {
1260 let safety_result = self.check_branch_deletion_safety(name)?;
1261 if let Some(safety_info) = safety_result {
1262 self.handle_branch_deletion_confirmation(name, &safety_info)?;
1264 }
1265 }
1266
1267 let mut branch = self
1268 .repo
1269 .find_branch(name, git2::BranchType::Local)
1270 .map_err(|e| CascadeError::branch(format!("Could not find branch '{name}': {e}")))?;
1271
1272 branch
1273 .delete()
1274 .map_err(|e| CascadeError::branch(format!("Could not delete branch '{name}': {e}")))?;
1275
1276 info!("Successfully deleted branch '{}'", name);
1277 Ok(())
1278 }
1279
1280 pub fn get_commits_between(&self, from: &str, to: &str) -> Result<Vec<git2::Commit<'_>>> {
1282 let from_oid = self
1283 .repo
1284 .refname_to_id(&format!("refs/heads/{from}"))
1285 .or_else(|_| Oid::from_str(from))
1286 .map_err(|e| CascadeError::branch(format!("Invalid from reference '{from}': {e}")))?;
1287
1288 let to_oid = self
1289 .repo
1290 .refname_to_id(&format!("refs/heads/{to}"))
1291 .or_else(|_| Oid::from_str(to))
1292 .map_err(|e| CascadeError::branch(format!("Invalid to reference '{to}': {e}")))?;
1293
1294 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
1295
1296 revwalk.push(to_oid).map_err(CascadeError::Git)?;
1297 revwalk.hide(from_oid).map_err(CascadeError::Git)?;
1298
1299 let mut commits = Vec::new();
1300 for oid in revwalk {
1301 let oid = oid.map_err(CascadeError::Git)?;
1302 let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
1303 commits.push(commit);
1304 }
1305
1306 Ok(commits)
1307 }
1308
1309 pub fn force_push_branch(&self, target_branch: &str, source_branch: &str) -> Result<()> {
1312 self.force_push_branch_with_options(target_branch, source_branch, false)
1313 }
1314
1315 pub fn force_push_branch_unsafe(&self, target_branch: &str, source_branch: &str) -> Result<()> {
1317 self.force_push_branch_with_options(target_branch, source_branch, true)
1318 }
1319
1320 fn force_push_branch_with_options(
1322 &self,
1323 target_branch: &str,
1324 source_branch: &str,
1325 force_unsafe: bool,
1326 ) -> Result<()> {
1327 info!(
1328 "Force pushing {} content to {} to preserve PR history",
1329 source_branch, target_branch
1330 );
1331
1332 if !force_unsafe {
1334 let safety_result = self.check_force_push_safety_enhanced(target_branch)?;
1335 if let Some(backup_info) = safety_result {
1336 self.create_backup_branch(target_branch, &backup_info.remote_commit_id)?;
1338 info!(
1339 "✅ Created backup branch: {}",
1340 backup_info.backup_branch_name
1341 );
1342 }
1343 }
1344
1345 let source_ref = self
1347 .repo
1348 .find_reference(&format!("refs/heads/{source_branch}"))
1349 .map_err(|e| {
1350 CascadeError::config(format!("Failed to find source branch {source_branch}: {e}"))
1351 })?;
1352 let source_commit = source_ref.peel_to_commit().map_err(|e| {
1353 CascadeError::config(format!(
1354 "Failed to get commit for source branch {source_branch}: {e}"
1355 ))
1356 })?;
1357
1358 let mut target_ref = self
1360 .repo
1361 .find_reference(&format!("refs/heads/{target_branch}"))
1362 .map_err(|e| {
1363 CascadeError::config(format!("Failed to find target branch {target_branch}: {e}"))
1364 })?;
1365
1366 target_ref
1367 .set_target(source_commit.id(), "Force push from rebase")
1368 .map_err(|e| {
1369 CascadeError::config(format!(
1370 "Failed to update target branch {target_branch}: {e}"
1371 ))
1372 })?;
1373
1374 let mut remote = self
1376 .repo
1377 .find_remote("origin")
1378 .map_err(|e| CascadeError::config(format!("Failed to find origin remote: {e}")))?;
1379
1380 let refspec = format!("+refs/heads/{target_branch}:refs/heads/{target_branch}");
1381
1382 let callbacks = self.configure_remote_callbacks()?;
1384
1385 let mut push_options = git2::PushOptions::new();
1387 push_options.remote_callbacks(callbacks);
1388
1389 match remote.push(&[&refspec], Some(&mut push_options)) {
1390 Ok(_) => {}
1391 Err(e) => {
1392 let error_string = e.to_string();
1394 if error_string.contains("TLS stream") || error_string.contains("SSL") {
1395 tracing::warn!(
1396 "git2 TLS error detected: {}, falling back to git CLI for force push operation",
1397 e
1398 );
1399 return self.force_push_with_git_cli(target_branch);
1400 }
1401 return Err(CascadeError::config(format!(
1402 "Failed to force push {target_branch}: {e}"
1403 )));
1404 }
1405 }
1406
1407 info!(
1408 "✅ Successfully force pushed {} to preserve PR history",
1409 target_branch
1410 );
1411 Ok(())
1412 }
1413
1414 fn check_force_push_safety_enhanced(
1417 &self,
1418 target_branch: &str,
1419 ) -> Result<Option<ForceBackupInfo>> {
1420 match self.fetch() {
1422 Ok(_) => {}
1423 Err(e) => {
1424 warn!("Could not fetch latest changes for safety check: {}", e);
1426 }
1427 }
1428
1429 let remote_ref = format!("refs/remotes/origin/{target_branch}");
1431 let local_ref = format!("refs/heads/{target_branch}");
1432
1433 let local_commit = match self.repo.find_reference(&local_ref) {
1435 Ok(reference) => reference.peel_to_commit().ok(),
1436 Err(_) => None,
1437 };
1438
1439 let remote_commit = match self.repo.find_reference(&remote_ref) {
1440 Ok(reference) => reference.peel_to_commit().ok(),
1441 Err(_) => None,
1442 };
1443
1444 if let (Some(local), Some(remote)) = (local_commit, remote_commit) {
1446 if local.id() != remote.id() {
1447 let merge_base_oid = self
1449 .repo
1450 .merge_base(local.id(), remote.id())
1451 .map_err(|e| CascadeError::config(format!("Failed to find merge base: {e}")))?;
1452
1453 if merge_base_oid != remote.id() {
1455 let commits_to_lose = self.count_commits_between(
1456 &merge_base_oid.to_string(),
1457 &remote.id().to_string(),
1458 )?;
1459
1460 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1462 let backup_branch_name = format!("{target_branch}_backup_{timestamp}");
1463
1464 warn!(
1465 "⚠️ Force push to '{}' would overwrite {} commits on remote",
1466 target_branch, commits_to_lose
1467 );
1468
1469 if std::env::var("CI").is_ok() || std::env::var("FORCE_PUSH_NO_CONFIRM").is_ok()
1471 {
1472 info!(
1473 "Non-interactive environment detected, proceeding with backup creation"
1474 );
1475 return Ok(Some(ForceBackupInfo {
1476 backup_branch_name,
1477 remote_commit_id: remote.id().to_string(),
1478 commits_that_would_be_lost: commits_to_lose,
1479 }));
1480 }
1481
1482 println!("\n⚠️ FORCE PUSH WARNING ⚠️");
1484 println!("Force push to '{target_branch}' would overwrite {commits_to_lose} commits on remote:");
1485
1486 match self
1488 .get_commits_between(&merge_base_oid.to_string(), &remote.id().to_string())
1489 {
1490 Ok(commits) => {
1491 println!("\nCommits that would be lost:");
1492 for (i, commit) in commits.iter().take(5).enumerate() {
1493 let short_hash = &commit.id().to_string()[..8];
1494 let summary = commit.summary().unwrap_or("<no message>");
1495 println!(" {}. {} - {}", i + 1, short_hash, summary);
1496 }
1497 if commits.len() > 5 {
1498 println!(" ... and {} more commits", commits.len() - 5);
1499 }
1500 }
1501 Err(_) => {
1502 println!(" (Unable to retrieve commit details)");
1503 }
1504 }
1505
1506 println!("\nA backup branch '{backup_branch_name}' will be created before proceeding.");
1507
1508 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
1509 .with_prompt("Do you want to proceed with the force push?")
1510 .default(false)
1511 .interact()
1512 .map_err(|e| {
1513 CascadeError::config(format!("Failed to get user confirmation: {e}"))
1514 })?;
1515
1516 if !confirmed {
1517 return Err(CascadeError::config(
1518 "Force push cancelled by user. Use --force to bypass this check."
1519 .to_string(),
1520 ));
1521 }
1522
1523 return Ok(Some(ForceBackupInfo {
1524 backup_branch_name,
1525 remote_commit_id: remote.id().to_string(),
1526 commits_that_would_be_lost: commits_to_lose,
1527 }));
1528 }
1529 }
1530 }
1531
1532 Ok(None)
1533 }
1534
1535 fn create_backup_branch(&self, original_branch: &str, remote_commit_id: &str) -> Result<()> {
1537 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1538 let backup_branch_name = format!("{original_branch}_backup_{timestamp}");
1539
1540 let commit_oid = Oid::from_str(remote_commit_id).map_err(|e| {
1542 CascadeError::config(format!("Invalid commit ID {remote_commit_id}: {e}"))
1543 })?;
1544
1545 let commit = self.repo.find_commit(commit_oid).map_err(|e| {
1547 CascadeError::config(format!("Failed to find commit {remote_commit_id}: {e}"))
1548 })?;
1549
1550 self.repo
1552 .branch(&backup_branch_name, &commit, false)
1553 .map_err(|e| {
1554 CascadeError::config(format!(
1555 "Failed to create backup branch {backup_branch_name}: {e}"
1556 ))
1557 })?;
1558
1559 info!(
1560 "✅ Created backup branch '{}' pointing to {}",
1561 backup_branch_name,
1562 &remote_commit_id[..8]
1563 );
1564 Ok(())
1565 }
1566
1567 fn check_branch_deletion_safety(
1570 &self,
1571 branch_name: &str,
1572 ) -> Result<Option<BranchDeletionSafety>> {
1573 match self.fetch() {
1575 Ok(_) => {}
1576 Err(e) => {
1577 warn!(
1578 "Could not fetch latest changes for branch deletion safety check: {}",
1579 e
1580 );
1581 }
1582 }
1583
1584 let branch = self
1586 .repo
1587 .find_branch(branch_name, git2::BranchType::Local)
1588 .map_err(|e| {
1589 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
1590 })?;
1591
1592 let _branch_commit = branch.get().peel_to_commit().map_err(|e| {
1593 CascadeError::branch(format!(
1594 "Could not get commit for branch '{branch_name}': {e}"
1595 ))
1596 })?;
1597
1598 let main_branch_name = self.detect_main_branch()?;
1600
1601 let is_merged_to_main = self.is_branch_merged_to_main(branch_name, &main_branch_name)?;
1603
1604 let remote_tracking_branch = self.get_remote_tracking_branch(branch_name);
1606
1607 let mut unpushed_commits = Vec::new();
1608
1609 if let Some(ref remote_branch) = remote_tracking_branch {
1611 match self.get_commits_between(remote_branch, branch_name) {
1612 Ok(commits) => {
1613 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1614 }
1615 Err(_) => {
1616 if !is_merged_to_main {
1618 if let Ok(commits) =
1619 self.get_commits_between(&main_branch_name, branch_name)
1620 {
1621 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1622 }
1623 }
1624 }
1625 }
1626 } else if !is_merged_to_main {
1627 if let Ok(commits) = self.get_commits_between(&main_branch_name, branch_name) {
1629 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1630 }
1631 }
1632
1633 if !unpushed_commits.is_empty() || (!is_merged_to_main && remote_tracking_branch.is_none())
1635 {
1636 Ok(Some(BranchDeletionSafety {
1637 unpushed_commits,
1638 remote_tracking_branch,
1639 is_merged_to_main,
1640 main_branch_name,
1641 }))
1642 } else {
1643 Ok(None)
1644 }
1645 }
1646
1647 fn handle_branch_deletion_confirmation(
1649 &self,
1650 branch_name: &str,
1651 safety_info: &BranchDeletionSafety,
1652 ) -> Result<()> {
1653 if std::env::var("CI").is_ok() || std::env::var("BRANCH_DELETE_NO_CONFIRM").is_ok() {
1655 return Err(CascadeError::branch(
1656 format!(
1657 "Branch '{branch_name}' has {} unpushed commits and cannot be deleted in non-interactive mode. Use --force to override.",
1658 safety_info.unpushed_commits.len()
1659 )
1660 ));
1661 }
1662
1663 println!("\n⚠️ BRANCH DELETION WARNING ⚠️");
1665 println!("Branch '{branch_name}' has potential issues:");
1666
1667 if !safety_info.unpushed_commits.is_empty() {
1668 println!(
1669 "\n🔍 Unpushed commits ({} total):",
1670 safety_info.unpushed_commits.len()
1671 );
1672
1673 for (i, commit_id) in safety_info.unpushed_commits.iter().take(5).enumerate() {
1675 if let Ok(commit) = self.repo.find_commit(Oid::from_str(commit_id).unwrap()) {
1676 let short_hash = &commit_id[..8];
1677 let summary = commit.summary().unwrap_or("<no message>");
1678 println!(" {}. {} - {}", i + 1, short_hash, summary);
1679 }
1680 }
1681
1682 if safety_info.unpushed_commits.len() > 5 {
1683 println!(
1684 " ... and {} more commits",
1685 safety_info.unpushed_commits.len() - 5
1686 );
1687 }
1688 }
1689
1690 if !safety_info.is_merged_to_main {
1691 println!("\n📋 Branch status:");
1692 println!(" • Not merged to '{}'", safety_info.main_branch_name);
1693 if let Some(ref remote) = safety_info.remote_tracking_branch {
1694 println!(" • Remote tracking branch: {remote}");
1695 } else {
1696 println!(" • No remote tracking branch");
1697 }
1698 }
1699
1700 println!("\n💡 Safer alternatives:");
1701 if !safety_info.unpushed_commits.is_empty() {
1702 if let Some(ref _remote) = safety_info.remote_tracking_branch {
1703 println!(" • Push commits first: git push origin {branch_name}");
1704 } else {
1705 println!(" • Create and push to remote: git push -u origin {branch_name}");
1706 }
1707 }
1708 if !safety_info.is_merged_to_main {
1709 println!(
1710 " • Merge to {} first: git checkout {} && git merge {branch_name}",
1711 safety_info.main_branch_name, safety_info.main_branch_name
1712 );
1713 }
1714
1715 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
1716 .with_prompt("Do you want to proceed with deleting this branch?")
1717 .default(false)
1718 .interact()
1719 .map_err(|e| CascadeError::branch(format!("Failed to get user confirmation: {e}")))?;
1720
1721 if !confirmed {
1722 return Err(CascadeError::branch(
1723 "Branch deletion cancelled by user. Use --force to bypass this check.".to_string(),
1724 ));
1725 }
1726
1727 Ok(())
1728 }
1729
1730 pub fn detect_main_branch(&self) -> Result<String> {
1732 let main_candidates = ["main", "master", "develop", "trunk"];
1733
1734 for candidate in &main_candidates {
1735 if self
1736 .repo
1737 .find_branch(candidate, git2::BranchType::Local)
1738 .is_ok()
1739 {
1740 return Ok(candidate.to_string());
1741 }
1742 }
1743
1744 if let Ok(head) = self.repo.head() {
1746 if let Some(name) = head.shorthand() {
1747 return Ok(name.to_string());
1748 }
1749 }
1750
1751 Ok("main".to_string())
1753 }
1754
1755 fn is_branch_merged_to_main(&self, branch_name: &str, main_branch: &str) -> Result<bool> {
1757 match self.get_commits_between(main_branch, branch_name) {
1759 Ok(commits) => Ok(commits.is_empty()),
1760 Err(_) => {
1761 Ok(false)
1763 }
1764 }
1765 }
1766
1767 fn get_remote_tracking_branch(&self, branch_name: &str) -> Option<String> {
1769 let remote_candidates = [
1771 format!("origin/{branch_name}"),
1772 format!("remotes/origin/{branch_name}"),
1773 ];
1774
1775 for candidate in &remote_candidates {
1776 if self
1777 .repo
1778 .find_reference(&format!(
1779 "refs/remotes/{}",
1780 candidate.replace("remotes/", "")
1781 ))
1782 .is_ok()
1783 {
1784 return Some(candidate.clone());
1785 }
1786 }
1787
1788 None
1789 }
1790
1791 fn check_checkout_safety(&self, _target: &str) -> Result<Option<CheckoutSafety>> {
1793 let is_dirty = self.is_dirty()?;
1795 if !is_dirty {
1796 return Ok(None);
1798 }
1799
1800 let current_branch = self.get_current_branch().ok();
1802
1803 let modified_files = self.get_modified_files()?;
1805 let staged_files = self.get_staged_files()?;
1806 let untracked_files = self.get_untracked_files()?;
1807
1808 let has_uncommitted_changes = !modified_files.is_empty() || !staged_files.is_empty();
1809
1810 if has_uncommitted_changes || !untracked_files.is_empty() {
1811 return Ok(Some(CheckoutSafety {
1812 has_uncommitted_changes,
1813 modified_files,
1814 staged_files,
1815 untracked_files,
1816 stash_created: None,
1817 current_branch,
1818 }));
1819 }
1820
1821 Ok(None)
1822 }
1823
1824 fn handle_checkout_confirmation(
1826 &self,
1827 target: &str,
1828 safety_info: &CheckoutSafety,
1829 ) -> Result<()> {
1830 let is_ci = std::env::var("CI").is_ok();
1832 let no_confirm = std::env::var("CHECKOUT_NO_CONFIRM").is_ok();
1833 let is_non_interactive = is_ci || no_confirm;
1834
1835 if is_non_interactive {
1836 return Err(CascadeError::branch(
1837 format!(
1838 "Cannot checkout '{target}' with uncommitted changes in non-interactive mode. Commit your changes or use stash first."
1839 )
1840 ));
1841 }
1842
1843 println!("\n⚠️ CHECKOUT WARNING ⚠️");
1845 println!("You have uncommitted changes that could be lost:");
1846
1847 if !safety_info.modified_files.is_empty() {
1848 println!(
1849 "\n📝 Modified files ({}):",
1850 safety_info.modified_files.len()
1851 );
1852 for file in safety_info.modified_files.iter().take(10) {
1853 println!(" - {file}");
1854 }
1855 if safety_info.modified_files.len() > 10 {
1856 println!(" ... and {} more", safety_info.modified_files.len() - 10);
1857 }
1858 }
1859
1860 if !safety_info.staged_files.is_empty() {
1861 println!("\n📁 Staged files ({}):", safety_info.staged_files.len());
1862 for file in safety_info.staged_files.iter().take(10) {
1863 println!(" - {file}");
1864 }
1865 if safety_info.staged_files.len() > 10 {
1866 println!(" ... and {} more", safety_info.staged_files.len() - 10);
1867 }
1868 }
1869
1870 if !safety_info.untracked_files.is_empty() {
1871 println!(
1872 "\n❓ Untracked files ({}):",
1873 safety_info.untracked_files.len()
1874 );
1875 for file in safety_info.untracked_files.iter().take(5) {
1876 println!(" - {file}");
1877 }
1878 if safety_info.untracked_files.len() > 5 {
1879 println!(" ... and {} more", safety_info.untracked_files.len() - 5);
1880 }
1881 }
1882
1883 println!("\n🔄 Options:");
1884 println!("1. Stash changes and checkout (recommended)");
1885 println!("2. Force checkout (WILL LOSE UNCOMMITTED CHANGES)");
1886 println!("3. Cancel checkout");
1887
1888 let confirmation = Confirm::with_theme(&ColorfulTheme::default())
1889 .with_prompt("Would you like to stash your changes and proceed with checkout?")
1890 .interact()
1891 .map_err(|e| CascadeError::branch(format!("Could not get user confirmation: {e}")))?;
1892
1893 if confirmation {
1894 let stash_message = format!(
1896 "Auto-stash before checkout to {} at {}",
1897 target,
1898 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
1899 );
1900
1901 match self.create_stash(&stash_message) {
1902 Ok(stash_oid) => {
1903 println!("✅ Created stash: {stash_message} ({stash_oid})");
1904 println!("💡 You can restore with: git stash pop");
1905 }
1906 Err(e) => {
1907 println!("❌ Failed to create stash: {e}");
1908
1909 let force_confirm = Confirm::with_theme(&ColorfulTheme::default())
1910 .with_prompt("Stash failed. Force checkout anyway? (WILL LOSE CHANGES)")
1911 .interact()
1912 .map_err(|e| {
1913 CascadeError::branch(format!("Could not get confirmation: {e}"))
1914 })?;
1915
1916 if !force_confirm {
1917 return Err(CascadeError::branch(
1918 "Checkout cancelled by user".to_string(),
1919 ));
1920 }
1921 }
1922 }
1923 } else {
1924 return Err(CascadeError::branch(
1925 "Checkout cancelled by user".to_string(),
1926 ));
1927 }
1928
1929 Ok(())
1930 }
1931
1932 fn create_stash(&self, message: &str) -> Result<String> {
1934 warn!("Automatic stashing not yet implemented - please stash manually");
1938 Err(CascadeError::branch(format!(
1939 "Please manually stash your changes first: git stash push -m \"{message}\""
1940 )))
1941 }
1942
1943 fn get_modified_files(&self) -> Result<Vec<String>> {
1945 let mut opts = git2::StatusOptions::new();
1946 opts.include_untracked(false).include_ignored(false);
1947
1948 let statuses = self
1949 .repo
1950 .statuses(Some(&mut opts))
1951 .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
1952
1953 let mut modified_files = Vec::new();
1954 for status in statuses.iter() {
1955 let flags = status.status();
1956 if flags.contains(git2::Status::WT_MODIFIED) || flags.contains(git2::Status::WT_DELETED)
1957 {
1958 if let Some(path) = status.path() {
1959 modified_files.push(path.to_string());
1960 }
1961 }
1962 }
1963
1964 Ok(modified_files)
1965 }
1966
1967 fn get_staged_files(&self) -> Result<Vec<String>> {
1969 let mut opts = git2::StatusOptions::new();
1970 opts.include_untracked(false).include_ignored(false);
1971
1972 let statuses = self
1973 .repo
1974 .statuses(Some(&mut opts))
1975 .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
1976
1977 let mut staged_files = Vec::new();
1978 for status in statuses.iter() {
1979 let flags = status.status();
1980 if flags.contains(git2::Status::INDEX_MODIFIED)
1981 || flags.contains(git2::Status::INDEX_NEW)
1982 || flags.contains(git2::Status::INDEX_DELETED)
1983 {
1984 if let Some(path) = status.path() {
1985 staged_files.push(path.to_string());
1986 }
1987 }
1988 }
1989
1990 Ok(staged_files)
1991 }
1992
1993 fn count_commits_between(&self, from: &str, to: &str) -> Result<usize> {
1995 let commits = self.get_commits_between(from, to)?;
1996 Ok(commits.len())
1997 }
1998
1999 pub fn resolve_reference(&self, reference: &str) -> Result<git2::Commit<'_>> {
2001 if let Ok(oid) = Oid::from_str(reference) {
2003 if let Ok(commit) = self.repo.find_commit(oid) {
2004 return Ok(commit);
2005 }
2006 }
2007
2008 let obj = self.repo.revparse_single(reference).map_err(|e| {
2010 CascadeError::branch(format!("Could not resolve reference '{reference}': {e}"))
2011 })?;
2012
2013 obj.peel_to_commit().map_err(|e| {
2014 CascadeError::branch(format!(
2015 "Reference '{reference}' does not point to a commit: {e}"
2016 ))
2017 })
2018 }
2019
2020 pub fn reset_soft(&self, target_ref: &str) -> Result<()> {
2022 let target_commit = self.resolve_reference(target_ref)?;
2023
2024 self.repo
2025 .reset(target_commit.as_object(), git2::ResetType::Soft, None)
2026 .map_err(CascadeError::Git)?;
2027
2028 Ok(())
2029 }
2030
2031 pub fn find_branch_containing_commit(&self, commit_hash: &str) -> Result<String> {
2033 let oid = Oid::from_str(commit_hash).map_err(|e| {
2034 CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
2035 })?;
2036
2037 let branches = self
2039 .repo
2040 .branches(Some(git2::BranchType::Local))
2041 .map_err(CascadeError::Git)?;
2042
2043 for branch_result in branches {
2044 let (branch, _) = branch_result.map_err(CascadeError::Git)?;
2045
2046 if let Some(branch_name) = branch.name().map_err(CascadeError::Git)? {
2047 if let Ok(branch_head) = branch.get().peel_to_commit() {
2049 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
2051 revwalk.push(branch_head.id()).map_err(CascadeError::Git)?;
2052
2053 for commit_oid in revwalk {
2054 let commit_oid = commit_oid.map_err(CascadeError::Git)?;
2055 if commit_oid == oid {
2056 return Ok(branch_name.to_string());
2057 }
2058 }
2059 }
2060 }
2061 }
2062
2063 Err(CascadeError::branch(format!(
2065 "Commit {commit_hash} not found in any local branch"
2066 )))
2067 }
2068
2069 pub async fn fetch_async(&self) -> Result<()> {
2073 let repo_path = self.path.clone();
2074 crate::utils::async_ops::run_git_operation(move || {
2075 let repo = GitRepository::open(&repo_path)?;
2076 repo.fetch()
2077 })
2078 .await
2079 }
2080
2081 pub async fn pull_async(&self, branch: &str) -> Result<()> {
2083 let repo_path = self.path.clone();
2084 let branch_name = branch.to_string();
2085 crate::utils::async_ops::run_git_operation(move || {
2086 let repo = GitRepository::open(&repo_path)?;
2087 repo.pull(&branch_name)
2088 })
2089 .await
2090 }
2091
2092 pub async fn push_branch_async(&self, branch_name: &str) -> Result<()> {
2094 let repo_path = self.path.clone();
2095 let branch = branch_name.to_string();
2096 crate::utils::async_ops::run_git_operation(move || {
2097 let repo = GitRepository::open(&repo_path)?;
2098 repo.push(&branch)
2099 })
2100 .await
2101 }
2102
2103 pub async fn cherry_pick_commit_async(&self, commit_hash: &str) -> Result<String> {
2105 let repo_path = self.path.clone();
2106 let hash = commit_hash.to_string();
2107 crate::utils::async_ops::run_git_operation(move || {
2108 let repo = GitRepository::open(&repo_path)?;
2109 repo.cherry_pick(&hash)
2110 })
2111 .await
2112 }
2113
2114 pub async fn get_commit_hashes_between_async(
2116 &self,
2117 from: &str,
2118 to: &str,
2119 ) -> Result<Vec<String>> {
2120 let repo_path = self.path.clone();
2121 let from_str = from.to_string();
2122 let to_str = to.to_string();
2123 crate::utils::async_ops::run_git_operation(move || {
2124 let repo = GitRepository::open(&repo_path)?;
2125 let commits = repo.get_commits_between(&from_str, &to_str)?;
2126 Ok(commits.into_iter().map(|c| c.id().to_string()).collect())
2127 })
2128 .await
2129 }
2130
2131 pub fn reset_branch_to_commit(&self, branch_name: &str, commit_hash: &str) -> Result<()> {
2133 info!(
2134 "Resetting branch '{}' to commit {}",
2135 branch_name,
2136 &commit_hash[..8]
2137 );
2138
2139 let target_oid = git2::Oid::from_str(commit_hash).map_err(|e| {
2141 CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
2142 })?;
2143
2144 let _target_commit = self.repo.find_commit(target_oid).map_err(|e| {
2145 CascadeError::branch(format!("Could not find commit '{commit_hash}': {e}"))
2146 })?;
2147
2148 let _branch = self
2150 .repo
2151 .find_branch(branch_name, git2::BranchType::Local)
2152 .map_err(|e| {
2153 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
2154 })?;
2155
2156 let branch_ref_name = format!("refs/heads/{branch_name}");
2158 self.repo
2159 .reference(
2160 &branch_ref_name,
2161 target_oid,
2162 true,
2163 &format!("Reset {branch_name} to {commit_hash}"),
2164 )
2165 .map_err(|e| {
2166 CascadeError::branch(format!(
2167 "Could not reset branch '{branch_name}' to commit '{commit_hash}': {e}"
2168 ))
2169 })?;
2170
2171 tracing::info!(
2172 "Successfully reset branch '{}' to commit {}",
2173 branch_name,
2174 &commit_hash[..8]
2175 );
2176 Ok(())
2177 }
2178}
2179
2180#[cfg(test)]
2181mod tests {
2182 use super::*;
2183 use std::process::Command;
2184 use tempfile::TempDir;
2185
2186 fn create_test_repo() -> (TempDir, PathBuf) {
2187 let temp_dir = TempDir::new().unwrap();
2188 let repo_path = temp_dir.path().to_path_buf();
2189
2190 Command::new("git")
2192 .args(["init"])
2193 .current_dir(&repo_path)
2194 .output()
2195 .unwrap();
2196 Command::new("git")
2197 .args(["config", "user.name", "Test"])
2198 .current_dir(&repo_path)
2199 .output()
2200 .unwrap();
2201 Command::new("git")
2202 .args(["config", "user.email", "test@test.com"])
2203 .current_dir(&repo_path)
2204 .output()
2205 .unwrap();
2206
2207 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
2209 Command::new("git")
2210 .args(["add", "."])
2211 .current_dir(&repo_path)
2212 .output()
2213 .unwrap();
2214 Command::new("git")
2215 .args(["commit", "-m", "Initial commit"])
2216 .current_dir(&repo_path)
2217 .output()
2218 .unwrap();
2219
2220 (temp_dir, repo_path)
2221 }
2222
2223 fn create_commit(repo_path: &PathBuf, message: &str, filename: &str) {
2224 let file_path = repo_path.join(filename);
2225 std::fs::write(&file_path, format!("Content for {filename}\n")).unwrap();
2226
2227 Command::new("git")
2228 .args(["add", filename])
2229 .current_dir(repo_path)
2230 .output()
2231 .unwrap();
2232 Command::new("git")
2233 .args(["commit", "-m", message])
2234 .current_dir(repo_path)
2235 .output()
2236 .unwrap();
2237 }
2238
2239 #[test]
2240 fn test_repository_info() {
2241 let (_temp_dir, repo_path) = create_test_repo();
2242 let repo = GitRepository::open(&repo_path).unwrap();
2243
2244 let info = repo.get_info().unwrap();
2245 assert!(!info.is_dirty); assert!(
2247 info.head_branch == Some("master".to_string())
2248 || info.head_branch == Some("main".to_string()),
2249 "Expected default branch to be 'master' or 'main', got {:?}",
2250 info.head_branch
2251 );
2252 assert!(info.head_commit.is_some()); assert!(info.untracked_files.is_empty()); }
2255
2256 #[test]
2257 fn test_force_push_branch_basic() {
2258 let (_temp_dir, repo_path) = create_test_repo();
2259 let repo = GitRepository::open(&repo_path).unwrap();
2260
2261 let default_branch = repo.get_current_branch().unwrap();
2263
2264 create_commit(&repo_path, "Feature commit 1", "feature1.rs");
2266 Command::new("git")
2267 .args(["checkout", "-b", "source-branch"])
2268 .current_dir(&repo_path)
2269 .output()
2270 .unwrap();
2271 create_commit(&repo_path, "Feature commit 2", "feature2.rs");
2272
2273 Command::new("git")
2275 .args(["checkout", &default_branch])
2276 .current_dir(&repo_path)
2277 .output()
2278 .unwrap();
2279 Command::new("git")
2280 .args(["checkout", "-b", "target-branch"])
2281 .current_dir(&repo_path)
2282 .output()
2283 .unwrap();
2284 create_commit(&repo_path, "Target commit", "target.rs");
2285
2286 let result = repo.force_push_branch("target-branch", "source-branch");
2288
2289 assert!(result.is_ok() || result.is_err()); }
2293
2294 #[test]
2295 fn test_force_push_branch_nonexistent_branches() {
2296 let (_temp_dir, repo_path) = create_test_repo();
2297 let repo = GitRepository::open(&repo_path).unwrap();
2298
2299 let default_branch = repo.get_current_branch().unwrap();
2301
2302 let result = repo.force_push_branch("target", "nonexistent-source");
2304 assert!(result.is_err());
2305
2306 let result = repo.force_push_branch("nonexistent-target", &default_branch);
2308 assert!(result.is_err());
2309 }
2310
2311 #[test]
2312 fn test_force_push_workflow_simulation() {
2313 let (_temp_dir, repo_path) = create_test_repo();
2314 let repo = GitRepository::open(&repo_path).unwrap();
2315
2316 Command::new("git")
2319 .args(["checkout", "-b", "feature-auth"])
2320 .current_dir(&repo_path)
2321 .output()
2322 .unwrap();
2323 create_commit(&repo_path, "Add authentication", "auth.rs");
2324
2325 Command::new("git")
2327 .args(["checkout", "-b", "feature-auth-v2"])
2328 .current_dir(&repo_path)
2329 .output()
2330 .unwrap();
2331 create_commit(&repo_path, "Fix auth validation", "auth.rs");
2332
2333 let result = repo.force_push_branch("feature-auth", "feature-auth-v2");
2335
2336 match result {
2338 Ok(_) => {
2339 Command::new("git")
2341 .args(["checkout", "feature-auth"])
2342 .current_dir(&repo_path)
2343 .output()
2344 .unwrap();
2345 let log_output = Command::new("git")
2346 .args(["log", "--oneline", "-2"])
2347 .current_dir(&repo_path)
2348 .output()
2349 .unwrap();
2350 let log_str = String::from_utf8_lossy(&log_output.stdout);
2351 assert!(
2352 log_str.contains("Fix auth validation")
2353 || log_str.contains("Add authentication")
2354 );
2355 }
2356 Err(_) => {
2357 }
2360 }
2361 }
2362
2363 #[test]
2364 fn test_branch_operations() {
2365 let (_temp_dir, repo_path) = create_test_repo();
2366 let repo = GitRepository::open(&repo_path).unwrap();
2367
2368 let current = repo.get_current_branch().unwrap();
2370 assert!(
2371 current == "master" || current == "main",
2372 "Expected default branch to be 'master' or 'main', got '{current}'"
2373 );
2374
2375 Command::new("git")
2377 .args(["checkout", "-b", "test-branch"])
2378 .current_dir(&repo_path)
2379 .output()
2380 .unwrap();
2381 let current = repo.get_current_branch().unwrap();
2382 assert_eq!(current, "test-branch");
2383 }
2384
2385 #[test]
2386 fn test_commit_operations() {
2387 let (_temp_dir, repo_path) = create_test_repo();
2388 let repo = GitRepository::open(&repo_path).unwrap();
2389
2390 let head = repo.get_head_commit().unwrap();
2392 assert_eq!(head.message().unwrap().trim(), "Initial commit");
2393
2394 let hash = head.id().to_string();
2396 let same_commit = repo.get_commit(&hash).unwrap();
2397 assert_eq!(head.id(), same_commit.id());
2398 }
2399
2400 #[test]
2401 fn test_checkout_safety_clean_repo() {
2402 let (_temp_dir, repo_path) = create_test_repo();
2403 let repo = GitRepository::open(&repo_path).unwrap();
2404
2405 create_commit(&repo_path, "Second commit", "test.txt");
2407 Command::new("git")
2408 .args(["checkout", "-b", "test-branch"])
2409 .current_dir(&repo_path)
2410 .output()
2411 .unwrap();
2412
2413 let safety_result = repo.check_checkout_safety("main");
2415 assert!(safety_result.is_ok());
2416 assert!(safety_result.unwrap().is_none()); }
2418
2419 #[test]
2420 fn test_checkout_safety_with_modified_files() {
2421 let (_temp_dir, repo_path) = create_test_repo();
2422 let repo = GitRepository::open(&repo_path).unwrap();
2423
2424 Command::new("git")
2426 .args(["checkout", "-b", "test-branch"])
2427 .current_dir(&repo_path)
2428 .output()
2429 .unwrap();
2430
2431 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2433
2434 let safety_result = repo.check_checkout_safety("main");
2436 assert!(safety_result.is_ok());
2437 let safety_info = safety_result.unwrap();
2438 assert!(safety_info.is_some());
2439
2440 let info = safety_info.unwrap();
2441 assert!(!info.modified_files.is_empty());
2442 assert!(info.modified_files.contains(&"README.md".to_string()));
2443 }
2444
2445 #[test]
2446 fn test_unsafe_checkout_methods() {
2447 let (_temp_dir, repo_path) = create_test_repo();
2448 let repo = GitRepository::open(&repo_path).unwrap();
2449
2450 create_commit(&repo_path, "Second commit", "test.txt");
2452 Command::new("git")
2453 .args(["checkout", "-b", "test-branch"])
2454 .current_dir(&repo_path)
2455 .output()
2456 .unwrap();
2457
2458 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2460
2461 let _result = repo.checkout_branch_unsafe("master");
2463 let head_commit = repo.get_head_commit().unwrap();
2468 let commit_hash = head_commit.id().to_string();
2469 let _result = repo.checkout_commit_unsafe(&commit_hash);
2470 }
2472
2473 #[test]
2474 fn test_get_modified_files() {
2475 let (_temp_dir, repo_path) = create_test_repo();
2476 let repo = GitRepository::open(&repo_path).unwrap();
2477
2478 let modified = repo.get_modified_files().unwrap();
2480 assert!(modified.is_empty());
2481
2482 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2484
2485 let modified = repo.get_modified_files().unwrap();
2487 assert_eq!(modified.len(), 1);
2488 assert!(modified.contains(&"README.md".to_string()));
2489 }
2490
2491 #[test]
2492 fn test_get_staged_files() {
2493 let (_temp_dir, repo_path) = create_test_repo();
2494 let repo = GitRepository::open(&repo_path).unwrap();
2495
2496 let staged = repo.get_staged_files().unwrap();
2498 assert!(staged.is_empty());
2499
2500 std::fs::write(repo_path.join("staged.txt"), "Staged content").unwrap();
2502 Command::new("git")
2503 .args(["add", "staged.txt"])
2504 .current_dir(&repo_path)
2505 .output()
2506 .unwrap();
2507
2508 let staged = repo.get_staged_files().unwrap();
2510 assert_eq!(staged.len(), 1);
2511 assert!(staged.contains(&"staged.txt".to_string()));
2512 }
2513
2514 #[test]
2515 fn test_create_stash_fallback() {
2516 let (_temp_dir, repo_path) = create_test_repo();
2517 let repo = GitRepository::open(&repo_path).unwrap();
2518
2519 let result = repo.create_stash("test stash");
2521 assert!(result.is_err());
2522 let error_msg = result.unwrap_err().to_string();
2523 assert!(error_msg.contains("git stash push"));
2524 }
2525
2526 #[test]
2527 fn test_delete_branch_unsafe() {
2528 let (_temp_dir, repo_path) = create_test_repo();
2529 let repo = GitRepository::open(&repo_path).unwrap();
2530
2531 create_commit(&repo_path, "Second commit", "test.txt");
2533 Command::new("git")
2534 .args(["checkout", "-b", "test-branch"])
2535 .current_dir(&repo_path)
2536 .output()
2537 .unwrap();
2538
2539 create_commit(&repo_path, "Branch-specific commit", "branch.txt");
2541
2542 Command::new("git")
2544 .args(["checkout", "master"])
2545 .current_dir(&repo_path)
2546 .output()
2547 .unwrap();
2548
2549 let result = repo.delete_branch_unsafe("test-branch");
2552 let _ = result; }
2556
2557 #[test]
2558 fn test_force_push_unsafe() {
2559 let (_temp_dir, repo_path) = create_test_repo();
2560 let repo = GitRepository::open(&repo_path).unwrap();
2561
2562 create_commit(&repo_path, "Second commit", "test.txt");
2564 Command::new("git")
2565 .args(["checkout", "-b", "test-branch"])
2566 .current_dir(&repo_path)
2567 .output()
2568 .unwrap();
2569
2570 let _result = repo.force_push_branch_unsafe("test-branch", "test-branch");
2573 }
2575}