1use crate::errors::{CascadeError, Result};
2use chrono;
3use dialoguer::{theme::ColorfulTheme, Confirm};
4use git2::{Oid, Repository, Signature};
5use std::path::{Path, PathBuf};
6use tracing::{info, warn};
7
8#[derive(Debug, Clone)]
10pub struct RepositoryInfo {
11 pub path: PathBuf,
12 pub head_branch: Option<String>,
13 pub head_commit: Option<String>,
14 pub is_dirty: bool,
15 pub untracked_files: Vec<String>,
16}
17
18#[derive(Debug, Clone)]
20struct ForceBackupInfo {
21 pub backup_branch_name: String,
22 pub remote_commit_id: String,
23 #[allow(dead_code)] pub commits_that_would_be_lost: usize,
25}
26
27#[derive(Debug, Clone)]
29struct BranchDeletionSafety {
30 pub unpushed_commits: Vec<String>,
31 pub remote_tracking_branch: Option<String>,
32 pub is_merged_to_main: bool,
33 pub main_branch_name: String,
34}
35
36#[derive(Debug, Clone)]
38struct CheckoutSafety {
39 #[allow(dead_code)] pub has_uncommitted_changes: bool,
41 pub modified_files: Vec<String>,
42 pub staged_files: Vec<String>,
43 pub untracked_files: Vec<String>,
44 #[allow(dead_code)] pub stash_created: Option<String>,
46 #[allow(dead_code)] pub current_branch: Option<String>,
48}
49
50#[derive(Debug, Clone)]
52pub struct GitSslConfig {
53 pub accept_invalid_certs: bool,
54 pub ca_bundle_path: Option<String>,
55}
56
57pub struct GitRepository {
63 repo: Repository,
64 path: PathBuf,
65 ssl_config: Option<GitSslConfig>,
66}
67
68impl GitRepository {
69 pub fn open(path: &Path) -> Result<Self> {
72 let repo = Repository::discover(path)
73 .map_err(|e| CascadeError::config(format!("Not a git repository: {e}")))?;
74
75 let workdir = repo
76 .workdir()
77 .ok_or_else(|| CascadeError::config("Repository has no working directory"))?
78 .to_path_buf();
79
80 let ssl_config = Self::load_ssl_config_from_cascade(&workdir);
82
83 Ok(Self {
84 repo,
85 path: workdir,
86 ssl_config,
87 })
88 }
89
90 fn load_ssl_config_from_cascade(repo_path: &Path) -> Option<GitSslConfig> {
92 let config_dir = crate::config::get_repo_config_dir(repo_path).ok()?;
94 let config_path = config_dir.join("config.json");
95 let settings = crate::config::Settings::load_from_file(&config_path).ok()?;
96
97 if settings.bitbucket.accept_invalid_certs.is_some()
99 || settings.bitbucket.ca_bundle_path.is_some()
100 {
101 Some(GitSslConfig {
102 accept_invalid_certs: settings.bitbucket.accept_invalid_certs.unwrap_or(false),
103 ca_bundle_path: settings.bitbucket.ca_bundle_path,
104 })
105 } else {
106 None
107 }
108 }
109
110 pub fn get_info(&self) -> Result<RepositoryInfo> {
112 let head_branch = self.get_current_branch().ok();
113 let head_commit = self.get_head_commit_hash().ok();
114 let is_dirty = self.is_dirty()?;
115 let untracked_files = self.get_untracked_files()?;
116
117 Ok(RepositoryInfo {
118 path: self.path.clone(),
119 head_branch,
120 head_commit,
121 is_dirty,
122 untracked_files,
123 })
124 }
125
126 pub fn get_current_branch(&self) -> Result<String> {
128 let head = self
129 .repo
130 .head()
131 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
132
133 if let Some(name) = head.shorthand() {
134 Ok(name.to_string())
135 } else {
136 let commit = head
138 .peel_to_commit()
139 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))?;
140 Ok(format!("HEAD@{}", commit.id()))
141 }
142 }
143
144 pub fn get_head_commit_hash(&self) -> Result<String> {
146 let head = self
147 .repo
148 .head()
149 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
150
151 let commit = head
152 .peel_to_commit()
153 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))?;
154
155 Ok(commit.id().to_string())
156 }
157
158 pub fn is_dirty(&self) -> Result<bool> {
160 let statuses = self.repo.statuses(None).map_err(CascadeError::Git)?;
161
162 for status in statuses.iter() {
163 let flags = status.status();
164
165 if flags.intersects(
167 git2::Status::INDEX_MODIFIED
168 | git2::Status::INDEX_NEW
169 | git2::Status::INDEX_DELETED
170 | git2::Status::WT_MODIFIED
171 | git2::Status::WT_NEW
172 | git2::Status::WT_DELETED,
173 ) {
174 return Ok(true);
175 }
176 }
177
178 Ok(false)
179 }
180
181 pub fn get_untracked_files(&self) -> Result<Vec<String>> {
183 let statuses = self.repo.statuses(None).map_err(CascadeError::Git)?;
184
185 let mut untracked = Vec::new();
186 for status in statuses.iter() {
187 if status.status().contains(git2::Status::WT_NEW) {
188 if let Some(path) = status.path() {
189 untracked.push(path.to_string());
190 }
191 }
192 }
193
194 Ok(untracked)
195 }
196
197 pub fn create_branch(&self, name: &str, target: Option<&str>) -> Result<()> {
199 let target_commit = if let Some(target) = target {
200 let target_obj = self.repo.revparse_single(target).map_err(|e| {
202 CascadeError::branch(format!("Could not find target '{target}': {e}"))
203 })?;
204 target_obj.peel_to_commit().map_err(|e| {
205 CascadeError::branch(format!("Target '{target}' is not a commit: {e}"))
206 })?
207 } else {
208 let head = self
210 .repo
211 .head()
212 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
213 head.peel_to_commit()
214 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))?
215 };
216
217 self.repo
218 .branch(name, &target_commit, false)
219 .map_err(|e| CascadeError::branch(format!("Could not create branch '{name}': {e}")))?;
220
221 tracing::info!("Created branch '{}'", name);
222 Ok(())
223 }
224
225 pub fn checkout_branch(&self, name: &str) -> Result<()> {
227 self.checkout_branch_with_options(name, false)
228 }
229
230 pub fn checkout_branch_unsafe(&self, name: &str) -> Result<()> {
232 self.checkout_branch_with_options(name, true)
233 }
234
235 fn checkout_branch_with_options(&self, name: &str, force_unsafe: bool) -> Result<()> {
237 info!("Attempting to checkout branch: {}", name);
238
239 if !force_unsafe {
241 let safety_result = self.check_checkout_safety(name)?;
242 if let Some(safety_info) = safety_result {
243 self.handle_checkout_confirmation(name, &safety_info)?;
245 }
246 }
247
248 let branch = self
250 .repo
251 .find_branch(name, git2::BranchType::Local)
252 .map_err(|e| CascadeError::branch(format!("Could not find branch '{name}': {e}")))?;
253
254 let branch_ref = branch.get();
255 let tree = branch_ref.peel_to_tree().map_err(|e| {
256 CascadeError::branch(format!("Could not get tree for branch '{name}': {e}"))
257 })?;
258
259 self.repo
261 .checkout_tree(tree.as_object(), None)
262 .map_err(|e| {
263 CascadeError::branch(format!("Could not checkout branch '{name}': {e}"))
264 })?;
265
266 self.repo
268 .set_head(&format!("refs/heads/{name}"))
269 .map_err(|e| CascadeError::branch(format!("Could not update HEAD to '{name}': {e}")))?;
270
271 tracing::info!("Switched to branch '{}'", name);
272 Ok(())
273 }
274
275 pub fn checkout_commit(&self, commit_hash: &str) -> Result<()> {
277 self.checkout_commit_with_options(commit_hash, false)
278 }
279
280 pub fn checkout_commit_unsafe(&self, commit_hash: &str) -> Result<()> {
282 self.checkout_commit_with_options(commit_hash, true)
283 }
284
285 fn checkout_commit_with_options(&self, commit_hash: &str, force_unsafe: bool) -> Result<()> {
287 info!("Attempting to checkout commit: {}", commit_hash);
288
289 if !force_unsafe {
291 let safety_result = self.check_checkout_safety(&format!("commit:{commit_hash}"))?;
292 if let Some(safety_info) = safety_result {
293 self.handle_checkout_confirmation(&format!("commit {commit_hash}"), &safety_info)?;
295 }
296 }
297
298 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
299
300 let commit = self.repo.find_commit(oid).map_err(|e| {
301 CascadeError::branch(format!("Could not find commit '{commit_hash}': {e}"))
302 })?;
303
304 let tree = commit.tree().map_err(|e| {
305 CascadeError::branch(format!(
306 "Could not get tree for commit '{commit_hash}': {e}"
307 ))
308 })?;
309
310 self.repo
312 .checkout_tree(tree.as_object(), None)
313 .map_err(|e| {
314 CascadeError::branch(format!("Could not checkout commit '{commit_hash}': {e}"))
315 })?;
316
317 self.repo.set_head_detached(oid).map_err(|e| {
319 CascadeError::branch(format!(
320 "Could not update HEAD to commit '{commit_hash}': {e}"
321 ))
322 })?;
323
324 tracing::info!("Checked out commit '{}' (detached HEAD)", commit_hash);
325 Ok(())
326 }
327
328 pub fn branch_exists(&self, name: &str) -> bool {
330 self.repo.find_branch(name, git2::BranchType::Local).is_ok()
331 }
332
333 pub fn branch_exists_or_fetch(&self, name: &str) -> Result<bool> {
335 if self.repo.find_branch(name, git2::BranchType::Local).is_ok() {
337 return Ok(true);
338 }
339
340 println!("🔍 Branch '{name}' not found locally, trying to fetch from remote...");
342
343 use std::process::Command;
344
345 let fetch_result = Command::new("git")
347 .args(["fetch", "origin", &format!("{name}:{name}")])
348 .current_dir(&self.path)
349 .output();
350
351 match fetch_result {
352 Ok(output) => {
353 if output.status.success() {
354 println!("✅ Successfully fetched '{name}' from origin");
355 return Ok(self.repo.find_branch(name, git2::BranchType::Local).is_ok());
357 } else {
358 let stderr = String::from_utf8_lossy(&output.stderr);
359 tracing::debug!("Failed to fetch branch '{name}': {stderr}");
360 }
361 }
362 Err(e) => {
363 tracing::debug!("Git fetch command failed: {e}");
364 }
365 }
366
367 if name.contains('/') {
369 println!("🔍 Trying alternative fetch patterns...");
370
371 let fetch_all_result = Command::new("git")
373 .args(["fetch", "origin"])
374 .current_dir(&self.path)
375 .output();
376
377 if let Ok(output) = fetch_all_result {
378 if output.status.success() {
379 let checkout_result = Command::new("git")
381 .args(["checkout", "-b", name, &format!("origin/{name}")])
382 .current_dir(&self.path)
383 .output();
384
385 if let Ok(checkout_output) = checkout_result {
386 if checkout_output.status.success() {
387 println!(
388 "✅ Successfully created local branch '{name}' from origin/{name}"
389 );
390 return Ok(true);
391 }
392 }
393 }
394 }
395 }
396
397 Ok(false)
399 }
400
401 pub fn get_branch_commit_hash(&self, branch_name: &str) -> Result<String> {
403 let branch = self
404 .repo
405 .find_branch(branch_name, git2::BranchType::Local)
406 .map_err(|e| {
407 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
408 })?;
409
410 let commit = branch.get().peel_to_commit().map_err(|e| {
411 CascadeError::branch(format!(
412 "Could not get commit for branch '{branch_name}': {e}"
413 ))
414 })?;
415
416 Ok(commit.id().to_string())
417 }
418
419 pub fn list_branches(&self) -> Result<Vec<String>> {
421 let branches = self
422 .repo
423 .branches(Some(git2::BranchType::Local))
424 .map_err(CascadeError::Git)?;
425
426 let mut branch_names = Vec::new();
427 for branch in branches {
428 let (branch, _) = branch.map_err(CascadeError::Git)?;
429 if let Some(name) = branch.name().map_err(CascadeError::Git)? {
430 branch_names.push(name.to_string());
431 }
432 }
433
434 Ok(branch_names)
435 }
436
437 pub fn commit(&self, message: &str) -> Result<String> {
439 let signature = self.get_signature()?;
440 let tree_id = self.get_index_tree()?;
441 let tree = self.repo.find_tree(tree_id).map_err(CascadeError::Git)?;
442
443 let head = self.repo.head().map_err(CascadeError::Git)?;
445 let parent_commit = head.peel_to_commit().map_err(CascadeError::Git)?;
446
447 let commit_id = self
448 .repo
449 .commit(
450 Some("HEAD"),
451 &signature,
452 &signature,
453 message,
454 &tree,
455 &[&parent_commit],
456 )
457 .map_err(CascadeError::Git)?;
458
459 tracing::info!("Created commit: {} - {}", commit_id, message);
460 Ok(commit_id.to_string())
461 }
462
463 pub fn stage_all(&self) -> Result<()> {
465 let mut index = self.repo.index().map_err(CascadeError::Git)?;
466
467 index
468 .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
469 .map_err(CascadeError::Git)?;
470
471 index.write().map_err(CascadeError::Git)?;
472
473 tracing::debug!("Staged all changes");
474 Ok(())
475 }
476
477 pub fn stage_files(&self, file_paths: &[&str]) -> Result<()> {
479 if file_paths.is_empty() {
480 tracing::debug!("No files to stage");
481 return Ok(());
482 }
483
484 let mut index = self.repo.index().map_err(CascadeError::Git)?;
485
486 for file_path in file_paths {
487 index
488 .add_path(std::path::Path::new(file_path))
489 .map_err(CascadeError::Git)?;
490 }
491
492 index.write().map_err(CascadeError::Git)?;
493
494 tracing::debug!(
495 "Staged {} specific files: {:?}",
496 file_paths.len(),
497 file_paths
498 );
499 Ok(())
500 }
501
502 pub fn stage_conflict_resolved_files(&self) -> Result<()> {
504 let conflicted_files = self.get_conflicted_files()?;
505 if conflicted_files.is_empty() {
506 tracing::debug!("No conflicted files to stage");
507 return Ok(());
508 }
509
510 let file_paths: Vec<&str> = conflicted_files.iter().map(|s| s.as_str()).collect();
511 self.stage_files(&file_paths)?;
512
513 tracing::debug!("Staged {} conflict-resolved files", conflicted_files.len());
514 Ok(())
515 }
516
517 pub fn path(&self) -> &Path {
519 &self.path
520 }
521
522 pub fn commit_exists(&self, commit_hash: &str) -> Result<bool> {
524 match Oid::from_str(commit_hash) {
525 Ok(oid) => match self.repo.find_commit(oid) {
526 Ok(_) => Ok(true),
527 Err(_) => Ok(false),
528 },
529 Err(_) => Ok(false),
530 }
531 }
532
533 pub fn get_head_commit(&self) -> Result<git2::Commit<'_>> {
535 let head = self
536 .repo
537 .head()
538 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
539 head.peel_to_commit()
540 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))
541 }
542
543 pub fn get_commit(&self, commit_hash: &str) -> Result<git2::Commit<'_>> {
545 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
546
547 self.repo.find_commit(oid).map_err(CascadeError::Git)
548 }
549
550 pub fn get_branch_head(&self, branch_name: &str) -> Result<String> {
552 let branch = self
553 .repo
554 .find_branch(branch_name, git2::BranchType::Local)
555 .map_err(|e| {
556 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
557 })?;
558
559 let commit = branch.get().peel_to_commit().map_err(|e| {
560 CascadeError::branch(format!(
561 "Could not get commit for branch '{branch_name}': {e}"
562 ))
563 })?;
564
565 Ok(commit.id().to_string())
566 }
567
568 fn get_signature(&self) -> Result<Signature<'_>> {
570 if let Ok(config) = self.repo.config() {
572 if let (Ok(name), Ok(email)) = (
573 config.get_string("user.name"),
574 config.get_string("user.email"),
575 ) {
576 return Signature::now(&name, &email).map_err(CascadeError::Git);
577 }
578 }
579
580 Signature::now("Cascade CLI", "cascade@example.com").map_err(CascadeError::Git)
582 }
583
584 fn configure_remote_callbacks(&self) -> Result<git2::RemoteCallbacks<'_>> {
587 let mut callbacks = git2::RemoteCallbacks::new();
588
589 callbacks.credentials(|_url, username_from_url, _allowed_types| {
591 if let Some(username) = username_from_url {
592 git2::Cred::ssh_key_from_agent(username)
594 } else {
595 git2::Cred::default()
597 }
598 });
599
600 let mut ssl_configured = false;
605
606 if let Some(ssl_config) = &self.ssl_config {
608 if ssl_config.accept_invalid_certs {
609 tracing::warn!(
610 "SSL certificate verification DISABLED via Cascade config - this is insecure!"
611 );
612 callbacks.certificate_check(|_cert, _host| {
613 tracing::debug!("⚠️ Accepting invalid certificate for host: {}", _host);
614 Ok(git2::CertificateCheckStatus::CertificateOk)
615 });
616 ssl_configured = true;
617 } else if let Some(ca_path) = &ssl_config.ca_bundle_path {
618 tracing::info!("Using custom CA bundle from Cascade config: {}", ca_path);
619 callbacks.certificate_check(|_cert, host| {
620 tracing::debug!("Using custom CA bundle for host: {}", host);
621 Ok(git2::CertificateCheckStatus::CertificateOk)
622 });
623 ssl_configured = true;
624 }
625 }
626
627 if !ssl_configured {
629 if let Ok(config) = self.repo.config() {
630 let ssl_verify = config.get_bool("http.sslVerify").unwrap_or(true);
631
632 if !ssl_verify {
633 tracing::warn!(
634 "SSL certificate verification DISABLED via git config - this is insecure!"
635 );
636 callbacks.certificate_check(|_cert, host| {
637 tracing::debug!("⚠️ Bypassing SSL verification for host: {}", host);
638 Ok(git2::CertificateCheckStatus::CertificateOk)
639 });
640 ssl_configured = true;
641 } else if let Ok(ca_path) = config.get_string("http.sslCAInfo") {
642 tracing::info!("Using custom CA bundle from git config: {}", ca_path);
643 callbacks.certificate_check(|_cert, host| {
644 tracing::debug!("Using git config CA bundle for host: {}", host);
645 Ok(git2::CertificateCheckStatus::CertificateOk)
646 });
647 ssl_configured = true;
648 }
649 }
650 }
651
652 if !ssl_configured {
655 tracing::debug!(
656 "Using system certificate store for SSL verification (default behavior)"
657 );
658
659 if cfg!(target_os = "macos") {
661 tracing::debug!("macOS detected - using default certificate validation");
662 } else {
665 callbacks.certificate_check(|_cert, host| {
667 tracing::debug!("System certificate validation for host: {}", host);
668 Ok(git2::CertificateCheckStatus::CertificatePassthrough)
669 });
670 }
671 }
672
673 Ok(callbacks)
674 }
675
676 fn get_index_tree(&self) -> Result<Oid> {
678 let mut index = self.repo.index().map_err(CascadeError::Git)?;
679
680 index.write_tree().map_err(CascadeError::Git)
681 }
682
683 pub fn get_status(&self) -> Result<git2::Statuses<'_>> {
685 self.repo.statuses(None).map_err(CascadeError::Git)
686 }
687
688 pub fn get_remote_url(&self, name: &str) -> Result<String> {
690 let remote = self.repo.find_remote(name).map_err(CascadeError::Git)?;
691 Ok(remote.url().unwrap_or("unknown").to_string())
692 }
693
694 pub fn diagnose_git2_support(&self) -> Result<()> {
697 let version = git2::Version::get();
698
699 println!("🔍 Git2 Feature Support Diagnosis:");
700 println!(" HTTPS/TLS support: {}", version.https());
701 println!(" SSH support: {}", version.ssh());
702
703 if !version.https() {
704 println!("❌ TLS streams NOT available - this explains TLS connection failures!");
705 println!(" Solution: Add 'https' feature to git2 dependency in Cargo.toml");
706 println!(" Current: git2 = {{ version = \"0.20.2\", default-features = false, features = [\"vendored-libgit2\"] }}");
707 println!(" Fixed: git2 = {{ version = \"0.20.2\", features = [\"vendored-libgit2\", \"https\", \"ssh\"] }}");
708 } else {
709 println!("✅ TLS streams available");
710 }
711
712 if !version.ssh() {
713 println!("❌ SSH support NOT available");
714 println!(" Add 'ssh' feature to git2 dependency");
715 } else {
716 println!("✅ SSH support available");
717 }
718
719 println!("\n📋 Additional git2 build information:");
721 let libgit2_version = version.libgit2_version();
722 println!(
723 " libgit2 version: {}.{}.{}",
724 libgit2_version.0, libgit2_version.1, libgit2_version.2
725 );
726
727 println!("\n💡 Recommendation:");
728 if !version.https() || !version.ssh() {
729 println!(" Your git2 is built without TLS/SSH support, causing fallback to git CLI.");
730 println!(" Enable the missing features in Cargo.toml for better performance and reliability.");
731 } else {
732 println!(
733 " git2 has full TLS/SSH support. Network issues may be configuration-related."
734 );
735 }
736
737 Ok(())
738 }
739
740 pub fn cherry_pick(&self, commit_hash: &str) -> Result<String> {
742 tracing::debug!("Cherry-picking commit {}", commit_hash);
743
744 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
745 let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
746
747 let commit_tree = commit.tree().map_err(CascadeError::Git)?;
749
750 let parent_commit = if commit.parent_count() > 0 {
752 commit.parent(0).map_err(CascadeError::Git)?
753 } else {
754 let empty_tree_oid = self.repo.treebuilder(None)?.write()?;
756 let empty_tree = self.repo.find_tree(empty_tree_oid)?;
757 let sig = self.get_signature()?;
758 return self
759 .repo
760 .commit(
761 Some("HEAD"),
762 &sig,
763 &sig,
764 commit.message().unwrap_or("Cherry-picked commit"),
765 &empty_tree,
766 &[],
767 )
768 .map(|oid| oid.to_string())
769 .map_err(CascadeError::Git);
770 };
771
772 let parent_tree = parent_commit.tree().map_err(CascadeError::Git)?;
773
774 let head_commit = self.get_head_commit()?;
776 let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
777
778 let mut index = self
780 .repo
781 .merge_trees(&parent_tree, &head_tree, &commit_tree, None)
782 .map_err(CascadeError::Git)?;
783
784 if index.has_conflicts() {
786 return Err(CascadeError::branch(format!(
787 "Cherry-pick of {commit_hash} has conflicts that need manual resolution"
788 )));
789 }
790
791 let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
793 let merged_tree = self
794 .repo
795 .find_tree(merged_tree_oid)
796 .map_err(CascadeError::Git)?;
797
798 let signature = self.get_signature()?;
800 let message = format!("Cherry-pick: {}", commit.message().unwrap_or(""));
801
802 let new_commit_oid = self
803 .repo
804 .commit(
805 Some("HEAD"),
806 &signature,
807 &signature,
808 &message,
809 &merged_tree,
810 &[&head_commit],
811 )
812 .map_err(CascadeError::Git)?;
813
814 tracing::info!("Cherry-picked {} -> {}", commit_hash, new_commit_oid);
815 Ok(new_commit_oid.to_string())
816 }
817
818 pub fn has_conflicts(&self) -> Result<bool> {
820 let index = self.repo.index().map_err(CascadeError::Git)?;
821 Ok(index.has_conflicts())
822 }
823
824 pub fn get_conflicted_files(&self) -> Result<Vec<String>> {
826 let index = self.repo.index().map_err(CascadeError::Git)?;
827
828 let mut conflicts = Vec::new();
829
830 let conflict_iter = index.conflicts().map_err(CascadeError::Git)?;
832
833 for conflict in conflict_iter {
834 let conflict = conflict.map_err(CascadeError::Git)?;
835 if let Some(our) = conflict.our {
836 if let Ok(path) = std::str::from_utf8(&our.path) {
837 conflicts.push(path.to_string());
838 }
839 } else if let Some(their) = conflict.their {
840 if let Ok(path) = std::str::from_utf8(&their.path) {
841 conflicts.push(path.to_string());
842 }
843 }
844 }
845
846 Ok(conflicts)
847 }
848
849 pub fn fetch(&self) -> Result<()> {
851 tracing::info!("Fetching from origin");
852
853 let mut remote = self
854 .repo
855 .find_remote("origin")
856 .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
857
858 let callbacks = self.configure_remote_callbacks()?;
860
861 let mut fetch_options = git2::FetchOptions::new();
863 fetch_options.remote_callbacks(callbacks);
864
865 match remote.fetch::<&str>(&[], Some(&mut fetch_options), None) {
867 Ok(_) => {
868 tracing::debug!("Fetch completed successfully");
869 Ok(())
870 }
871 Err(e) => {
872 let error_string = e.to_string();
874 if error_string.contains("TLS stream") || error_string.contains("SSL") {
875 tracing::warn!(
876 "git2 TLS error detected: {}, falling back to git CLI for fetch operation",
877 e
878 );
879 return self.fetch_with_git_cli();
880 }
881 Err(CascadeError::Git(e))
882 }
883 }
884 }
885
886 pub fn pull(&self, branch: &str) -> Result<()> {
888 tracing::info!("Pulling branch: {}", branch);
889
890 match self.fetch() {
892 Ok(_) => {}
893 Err(e) => {
894 let error_string = e.to_string();
896 if error_string.contains("TLS stream") || error_string.contains("SSL") {
897 tracing::warn!(
898 "git2 TLS error detected: {}, falling back to git CLI for pull operation",
899 e
900 );
901 return self.pull_with_git_cli(branch);
902 }
903 return Err(e);
904 }
905 }
906
907 let remote_branch_name = format!("origin/{branch}");
909 let remote_oid = self
910 .repo
911 .refname_to_id(&format!("refs/remotes/{remote_branch_name}"))
912 .map_err(|e| {
913 CascadeError::branch(format!("Remote branch {remote_branch_name} not found: {e}"))
914 })?;
915
916 let remote_commit = self
917 .repo
918 .find_commit(remote_oid)
919 .map_err(CascadeError::Git)?;
920
921 let head_commit = self.get_head_commit()?;
923
924 if head_commit.id() == remote_commit.id() {
926 tracing::debug!("Already up to date");
927 return Ok(());
928 }
929
930 let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
932 let remote_tree = remote_commit.tree().map_err(CascadeError::Git)?;
933
934 let merge_base_oid = self
936 .repo
937 .merge_base(head_commit.id(), remote_commit.id())
938 .map_err(CascadeError::Git)?;
939 let merge_base_commit = self
940 .repo
941 .find_commit(merge_base_oid)
942 .map_err(CascadeError::Git)?;
943 let merge_base_tree = merge_base_commit.tree().map_err(CascadeError::Git)?;
944
945 let mut index = self
947 .repo
948 .merge_trees(&merge_base_tree, &head_tree, &remote_tree, None)
949 .map_err(CascadeError::Git)?;
950
951 if index.has_conflicts() {
952 return Err(CascadeError::branch(
953 "Pull has conflicts that need manual resolution".to_string(),
954 ));
955 }
956
957 let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
959 let merged_tree = self
960 .repo
961 .find_tree(merged_tree_oid)
962 .map_err(CascadeError::Git)?;
963
964 let signature = self.get_signature()?;
965 let message = format!("Merge branch '{branch}' from origin");
966
967 self.repo
968 .commit(
969 Some("HEAD"),
970 &signature,
971 &signature,
972 &message,
973 &merged_tree,
974 &[&head_commit, &remote_commit],
975 )
976 .map_err(CascadeError::Git)?;
977
978 tracing::info!("Pull completed successfully");
979 Ok(())
980 }
981
982 pub fn push(&self, branch: &str) -> Result<()> {
984 tracing::info!("Pushing branch: {}", branch);
985
986 let mut remote = self
987 .repo
988 .find_remote("origin")
989 .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
990
991 let remote_url = remote.url().unwrap_or("unknown").to_string();
992 tracing::debug!("Remote URL: {}", remote_url);
993
994 let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
995 tracing::debug!("Push refspec: {}", refspec);
996
997 let mut callbacks = self.configure_remote_callbacks()?;
999
1000 callbacks.push_update_reference(|refname, status| {
1002 if let Some(msg) = status {
1003 tracing::error!("Push failed for ref {}: {}", refname, msg);
1004 return Err(git2::Error::from_str(&format!("Push failed: {msg}")));
1005 }
1006 tracing::debug!("Push succeeded for ref: {}", refname);
1007 Ok(())
1008 });
1009
1010 let mut push_options = git2::PushOptions::new();
1012 push_options.remote_callbacks(callbacks);
1013
1014 match remote.push(&[&refspec], Some(&mut push_options)) {
1016 Ok(_) => {
1017 tracing::info!("Push completed successfully for branch: {}", branch);
1018 Ok(())
1019 }
1020 Err(e) => {
1021 let error_string = e.to_string();
1023 tracing::debug!("git2 push error: {} (class: {:?})", error_string, e.class());
1024
1025 if error_string.contains("TLS stream")
1026 || error_string.contains("SSL")
1027 || e.class() == git2::ErrorClass::Ssl
1028 {
1029 tracing::info!(
1030 "git2 TLS/SSL error: {}, falling back to git CLI for push operation",
1031 e
1032 );
1033 return self.push_with_git_cli(branch);
1034 }
1035
1036 let error_msg = format!(
1038 "Failed to push branch '{}' to remote '{}': {}. \
1039 \nDebugging hints:\
1040 \n - Check network connectivity: ping {}\
1041 \n - Verify authentication: git remote -v\
1042 \n - Test manual push: git push origin {}\
1043 \n - Check SSL settings if using HTTPS\
1044 \n - For corporate networks, consider SSL certificate configuration",
1045 branch,
1046 remote_url,
1047 e,
1048 remote_url.split("://").nth(1).unwrap_or("unknown"),
1049 branch
1050 );
1051
1052 tracing::error!("{}", error_msg);
1053 Err(CascadeError::branch(error_msg))
1054 }
1055 }
1056 }
1057
1058 fn push_with_git_cli(&self, branch: &str) -> Result<()> {
1061 tracing::info!("Using git CLI fallback for push operation: {}", branch);
1062
1063 let output = std::process::Command::new("git")
1064 .args(["push", "origin", branch])
1065 .current_dir(&self.path)
1066 .output()
1067 .map_err(|e| CascadeError::branch(format!("Failed to execute git command: {e}")))?;
1068
1069 if output.status.success() {
1070 tracing::info!("✅ Git CLI push succeeded for branch: {}", branch);
1071 Ok(())
1072 } else {
1073 let stderr = String::from_utf8_lossy(&output.stderr);
1074 let stdout = String::from_utf8_lossy(&output.stdout);
1075 let error_msg = format!(
1076 "Git CLI push failed for branch '{}': {}\nStdout: {}\nStderr: {}",
1077 branch, output.status, stdout, stderr
1078 );
1079 tracing::error!("{}", error_msg);
1080 Err(CascadeError::branch(error_msg))
1081 }
1082 }
1083
1084 fn fetch_with_git_cli(&self) -> Result<()> {
1087 tracing::info!("Using git CLI fallback for fetch operation");
1088
1089 let output = std::process::Command::new("git")
1090 .args(["fetch", "origin"])
1091 .current_dir(&self.path)
1092 .output()
1093 .map_err(|e| {
1094 CascadeError::Git(git2::Error::from_str(&format!(
1095 "Failed to execute git command: {e}"
1096 )))
1097 })?;
1098
1099 if output.status.success() {
1100 tracing::info!("✅ Git CLI fetch succeeded");
1101 Ok(())
1102 } else {
1103 let stderr = String::from_utf8_lossy(&output.stderr);
1104 let stdout = String::from_utf8_lossy(&output.stdout);
1105 let error_msg = format!(
1106 "Git CLI fetch failed: {}\nStdout: {}\nStderr: {}",
1107 output.status, stdout, stderr
1108 );
1109 tracing::error!("{}", error_msg);
1110 Err(CascadeError::Git(git2::Error::from_str(&error_msg)))
1111 }
1112 }
1113
1114 fn pull_with_git_cli(&self, branch: &str) -> Result<()> {
1117 tracing::info!("Using git CLI fallback for pull operation: {}", branch);
1118
1119 let output = std::process::Command::new("git")
1120 .args(["pull", "origin", branch])
1121 .current_dir(&self.path)
1122 .output()
1123 .map_err(|e| {
1124 CascadeError::Git(git2::Error::from_str(&format!(
1125 "Failed to execute git command: {e}"
1126 )))
1127 })?;
1128
1129 if output.status.success() {
1130 tracing::info!("✅ Git CLI pull succeeded for branch: {}", branch);
1131 Ok(())
1132 } else {
1133 let stderr = String::from_utf8_lossy(&output.stderr);
1134 let stdout = String::from_utf8_lossy(&output.stdout);
1135 let error_msg = format!(
1136 "Git CLI pull failed for branch '{}': {}\nStdout: {}\nStderr: {}",
1137 branch, output.status, stdout, stderr
1138 );
1139 tracing::error!("{}", error_msg);
1140 Err(CascadeError::Git(git2::Error::from_str(&error_msg)))
1141 }
1142 }
1143
1144 fn force_push_with_git_cli(&self, branch: &str) -> Result<()> {
1147 tracing::info!(
1148 "Using git CLI fallback for force push operation: {}",
1149 branch
1150 );
1151
1152 let output = std::process::Command::new("git")
1153 .args(["push", "--force", "origin", branch])
1154 .current_dir(&self.path)
1155 .output()
1156 .map_err(|e| CascadeError::branch(format!("Failed to execute git command: {e}")))?;
1157
1158 if output.status.success() {
1159 tracing::info!("✅ Git CLI force push succeeded for branch: {}", branch);
1160 Ok(())
1161 } else {
1162 let stderr = String::from_utf8_lossy(&output.stderr);
1163 let stdout = String::from_utf8_lossy(&output.stdout);
1164 let error_msg = format!(
1165 "Git CLI force push failed for branch '{}': {}\nStdout: {}\nStderr: {}",
1166 branch, output.status, stdout, stderr
1167 );
1168 tracing::error!("{}", error_msg);
1169 Err(CascadeError::branch(error_msg))
1170 }
1171 }
1172
1173 pub fn delete_branch(&self, name: &str) -> Result<()> {
1175 self.delete_branch_with_options(name, false)
1176 }
1177
1178 pub fn delete_branch_unsafe(&self, name: &str) -> Result<()> {
1180 self.delete_branch_with_options(name, true)
1181 }
1182
1183 fn delete_branch_with_options(&self, name: &str, force_unsafe: bool) -> Result<()> {
1185 info!("Attempting to delete branch: {}", name);
1186
1187 if !force_unsafe {
1189 let safety_result = self.check_branch_deletion_safety(name)?;
1190 if let Some(safety_info) = safety_result {
1191 self.handle_branch_deletion_confirmation(name, &safety_info)?;
1193 }
1194 }
1195
1196 let mut branch = self
1197 .repo
1198 .find_branch(name, git2::BranchType::Local)
1199 .map_err(|e| CascadeError::branch(format!("Could not find branch '{name}': {e}")))?;
1200
1201 branch
1202 .delete()
1203 .map_err(|e| CascadeError::branch(format!("Could not delete branch '{name}': {e}")))?;
1204
1205 info!("Successfully deleted branch '{}'", name);
1206 Ok(())
1207 }
1208
1209 pub fn get_commits_between(&self, from: &str, to: &str) -> Result<Vec<git2::Commit<'_>>> {
1211 let from_oid = self
1212 .repo
1213 .refname_to_id(&format!("refs/heads/{from}"))
1214 .or_else(|_| Oid::from_str(from))
1215 .map_err(|e| CascadeError::branch(format!("Invalid from reference '{from}': {e}")))?;
1216
1217 let to_oid = self
1218 .repo
1219 .refname_to_id(&format!("refs/heads/{to}"))
1220 .or_else(|_| Oid::from_str(to))
1221 .map_err(|e| CascadeError::branch(format!("Invalid to reference '{to}': {e}")))?;
1222
1223 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
1224
1225 revwalk.push(to_oid).map_err(CascadeError::Git)?;
1226 revwalk.hide(from_oid).map_err(CascadeError::Git)?;
1227
1228 let mut commits = Vec::new();
1229 for oid in revwalk {
1230 let oid = oid.map_err(CascadeError::Git)?;
1231 let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
1232 commits.push(commit);
1233 }
1234
1235 Ok(commits)
1236 }
1237
1238 pub fn force_push_branch(&self, target_branch: &str, source_branch: &str) -> Result<()> {
1241 self.force_push_branch_with_options(target_branch, source_branch, false)
1242 }
1243
1244 pub fn force_push_branch_unsafe(&self, target_branch: &str, source_branch: &str) -> Result<()> {
1246 self.force_push_branch_with_options(target_branch, source_branch, true)
1247 }
1248
1249 fn force_push_branch_with_options(
1251 &self,
1252 target_branch: &str,
1253 source_branch: &str,
1254 force_unsafe: bool,
1255 ) -> Result<()> {
1256 info!(
1257 "Force pushing {} content to {} to preserve PR history",
1258 source_branch, target_branch
1259 );
1260
1261 if !force_unsafe {
1263 let safety_result = self.check_force_push_safety_enhanced(target_branch)?;
1264 if let Some(backup_info) = safety_result {
1265 self.create_backup_branch(target_branch, &backup_info.remote_commit_id)?;
1267 info!(
1268 "✅ Created backup branch: {}",
1269 backup_info.backup_branch_name
1270 );
1271 }
1272 }
1273
1274 let source_ref = self
1276 .repo
1277 .find_reference(&format!("refs/heads/{source_branch}"))
1278 .map_err(|e| {
1279 CascadeError::config(format!("Failed to find source branch {source_branch}: {e}"))
1280 })?;
1281 let source_commit = source_ref.peel_to_commit().map_err(|e| {
1282 CascadeError::config(format!(
1283 "Failed to get commit for source branch {source_branch}: {e}"
1284 ))
1285 })?;
1286
1287 let mut target_ref = self
1289 .repo
1290 .find_reference(&format!("refs/heads/{target_branch}"))
1291 .map_err(|e| {
1292 CascadeError::config(format!("Failed to find target branch {target_branch}: {e}"))
1293 })?;
1294
1295 target_ref
1296 .set_target(source_commit.id(), "Force push from rebase")
1297 .map_err(|e| {
1298 CascadeError::config(format!(
1299 "Failed to update target branch {target_branch}: {e}"
1300 ))
1301 })?;
1302
1303 let mut remote = self
1305 .repo
1306 .find_remote("origin")
1307 .map_err(|e| CascadeError::config(format!("Failed to find origin remote: {e}")))?;
1308
1309 let refspec = format!("+refs/heads/{target_branch}:refs/heads/{target_branch}");
1310
1311 let callbacks = self.configure_remote_callbacks()?;
1313
1314 let mut push_options = git2::PushOptions::new();
1316 push_options.remote_callbacks(callbacks);
1317
1318 match remote.push(&[&refspec], Some(&mut push_options)) {
1319 Ok(_) => {}
1320 Err(e) => {
1321 let error_string = e.to_string();
1323 if error_string.contains("TLS stream") || error_string.contains("SSL") {
1324 tracing::warn!(
1325 "git2 TLS error detected: {}, falling back to git CLI for force push operation",
1326 e
1327 );
1328 return self.force_push_with_git_cli(target_branch);
1329 }
1330 return Err(CascadeError::config(format!(
1331 "Failed to force push {target_branch}: {e}"
1332 )));
1333 }
1334 }
1335
1336 info!(
1337 "✅ Successfully force pushed {} to preserve PR history",
1338 target_branch
1339 );
1340 Ok(())
1341 }
1342
1343 fn check_force_push_safety_enhanced(
1346 &self,
1347 target_branch: &str,
1348 ) -> Result<Option<ForceBackupInfo>> {
1349 match self.fetch() {
1351 Ok(_) => {}
1352 Err(e) => {
1353 warn!("Could not fetch latest changes for safety check: {}", e);
1355 }
1356 }
1357
1358 let remote_ref = format!("refs/remotes/origin/{target_branch}");
1360 let local_ref = format!("refs/heads/{target_branch}");
1361
1362 let local_commit = match self.repo.find_reference(&local_ref) {
1364 Ok(reference) => reference.peel_to_commit().ok(),
1365 Err(_) => None,
1366 };
1367
1368 let remote_commit = match self.repo.find_reference(&remote_ref) {
1369 Ok(reference) => reference.peel_to_commit().ok(),
1370 Err(_) => None,
1371 };
1372
1373 if let (Some(local), Some(remote)) = (local_commit, remote_commit) {
1375 if local.id() != remote.id() {
1376 let merge_base_oid = self
1378 .repo
1379 .merge_base(local.id(), remote.id())
1380 .map_err(|e| CascadeError::config(format!("Failed to find merge base: {e}")))?;
1381
1382 if merge_base_oid != remote.id() {
1384 let commits_to_lose = self.count_commits_between(
1385 &merge_base_oid.to_string(),
1386 &remote.id().to_string(),
1387 )?;
1388
1389 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1391 let backup_branch_name = format!("{target_branch}_backup_{timestamp}");
1392
1393 warn!(
1394 "⚠️ Force push to '{}' would overwrite {} commits on remote",
1395 target_branch, commits_to_lose
1396 );
1397
1398 if std::env::var("CI").is_ok() || std::env::var("FORCE_PUSH_NO_CONFIRM").is_ok()
1400 {
1401 info!(
1402 "Non-interactive environment detected, proceeding with backup creation"
1403 );
1404 return Ok(Some(ForceBackupInfo {
1405 backup_branch_name,
1406 remote_commit_id: remote.id().to_string(),
1407 commits_that_would_be_lost: commits_to_lose,
1408 }));
1409 }
1410
1411 println!("\n⚠️ FORCE PUSH WARNING ⚠️");
1413 println!("Force push to '{target_branch}' would overwrite {commits_to_lose} commits on remote:");
1414
1415 match self
1417 .get_commits_between(&merge_base_oid.to_string(), &remote.id().to_string())
1418 {
1419 Ok(commits) => {
1420 println!("\nCommits that would be lost:");
1421 for (i, commit) in commits.iter().take(5).enumerate() {
1422 let short_hash = &commit.id().to_string()[..8];
1423 let summary = commit.summary().unwrap_or("<no message>");
1424 println!(" {}. {} - {}", i + 1, short_hash, summary);
1425 }
1426 if commits.len() > 5 {
1427 println!(" ... and {} more commits", commits.len() - 5);
1428 }
1429 }
1430 Err(_) => {
1431 println!(" (Unable to retrieve commit details)");
1432 }
1433 }
1434
1435 println!("\nA backup branch '{backup_branch_name}' will be created before proceeding.");
1436
1437 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
1438 .with_prompt("Do you want to proceed with the force push?")
1439 .default(false)
1440 .interact()
1441 .map_err(|e| {
1442 CascadeError::config(format!("Failed to get user confirmation: {e}"))
1443 })?;
1444
1445 if !confirmed {
1446 return Err(CascadeError::config(
1447 "Force push cancelled by user. Use --force to bypass this check."
1448 .to_string(),
1449 ));
1450 }
1451
1452 return Ok(Some(ForceBackupInfo {
1453 backup_branch_name,
1454 remote_commit_id: remote.id().to_string(),
1455 commits_that_would_be_lost: commits_to_lose,
1456 }));
1457 }
1458 }
1459 }
1460
1461 Ok(None)
1462 }
1463
1464 fn create_backup_branch(&self, original_branch: &str, remote_commit_id: &str) -> Result<()> {
1466 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1467 let backup_branch_name = format!("{original_branch}_backup_{timestamp}");
1468
1469 let commit_oid = Oid::from_str(remote_commit_id).map_err(|e| {
1471 CascadeError::config(format!("Invalid commit ID {remote_commit_id}: {e}"))
1472 })?;
1473
1474 let commit = self.repo.find_commit(commit_oid).map_err(|e| {
1476 CascadeError::config(format!("Failed to find commit {remote_commit_id}: {e}"))
1477 })?;
1478
1479 self.repo
1481 .branch(&backup_branch_name, &commit, false)
1482 .map_err(|e| {
1483 CascadeError::config(format!(
1484 "Failed to create backup branch {backup_branch_name}: {e}"
1485 ))
1486 })?;
1487
1488 info!(
1489 "✅ Created backup branch '{}' pointing to {}",
1490 backup_branch_name,
1491 &remote_commit_id[..8]
1492 );
1493 Ok(())
1494 }
1495
1496 fn check_branch_deletion_safety(
1499 &self,
1500 branch_name: &str,
1501 ) -> Result<Option<BranchDeletionSafety>> {
1502 match self.fetch() {
1504 Ok(_) => {}
1505 Err(e) => {
1506 warn!(
1507 "Could not fetch latest changes for branch deletion safety check: {}",
1508 e
1509 );
1510 }
1511 }
1512
1513 let branch = self
1515 .repo
1516 .find_branch(branch_name, git2::BranchType::Local)
1517 .map_err(|e| {
1518 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
1519 })?;
1520
1521 let _branch_commit = branch.get().peel_to_commit().map_err(|e| {
1522 CascadeError::branch(format!(
1523 "Could not get commit for branch '{branch_name}': {e}"
1524 ))
1525 })?;
1526
1527 let main_branch_name = self.detect_main_branch()?;
1529
1530 let is_merged_to_main = self.is_branch_merged_to_main(branch_name, &main_branch_name)?;
1532
1533 let remote_tracking_branch = self.get_remote_tracking_branch(branch_name);
1535
1536 let mut unpushed_commits = Vec::new();
1537
1538 if let Some(ref remote_branch) = remote_tracking_branch {
1540 match self.get_commits_between(remote_branch, branch_name) {
1541 Ok(commits) => {
1542 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1543 }
1544 Err(_) => {
1545 if !is_merged_to_main {
1547 if let Ok(commits) =
1548 self.get_commits_between(&main_branch_name, branch_name)
1549 {
1550 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1551 }
1552 }
1553 }
1554 }
1555 } else if !is_merged_to_main {
1556 if let Ok(commits) = self.get_commits_between(&main_branch_name, branch_name) {
1558 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1559 }
1560 }
1561
1562 if !unpushed_commits.is_empty() || (!is_merged_to_main && remote_tracking_branch.is_none())
1564 {
1565 Ok(Some(BranchDeletionSafety {
1566 unpushed_commits,
1567 remote_tracking_branch,
1568 is_merged_to_main,
1569 main_branch_name,
1570 }))
1571 } else {
1572 Ok(None)
1573 }
1574 }
1575
1576 fn handle_branch_deletion_confirmation(
1578 &self,
1579 branch_name: &str,
1580 safety_info: &BranchDeletionSafety,
1581 ) -> Result<()> {
1582 if std::env::var("CI").is_ok() || std::env::var("BRANCH_DELETE_NO_CONFIRM").is_ok() {
1584 return Err(CascadeError::branch(
1585 format!(
1586 "Branch '{branch_name}' has {} unpushed commits and cannot be deleted in non-interactive mode. Use --force to override.",
1587 safety_info.unpushed_commits.len()
1588 )
1589 ));
1590 }
1591
1592 println!("\n⚠️ BRANCH DELETION WARNING ⚠️");
1594 println!("Branch '{branch_name}' has potential issues:");
1595
1596 if !safety_info.unpushed_commits.is_empty() {
1597 println!(
1598 "\n🔍 Unpushed commits ({} total):",
1599 safety_info.unpushed_commits.len()
1600 );
1601
1602 for (i, commit_id) in safety_info.unpushed_commits.iter().take(5).enumerate() {
1604 if let Ok(commit) = self.repo.find_commit(Oid::from_str(commit_id).unwrap()) {
1605 let short_hash = &commit_id[..8];
1606 let summary = commit.summary().unwrap_or("<no message>");
1607 println!(" {}. {} - {}", i + 1, short_hash, summary);
1608 }
1609 }
1610
1611 if safety_info.unpushed_commits.len() > 5 {
1612 println!(
1613 " ... and {} more commits",
1614 safety_info.unpushed_commits.len() - 5
1615 );
1616 }
1617 }
1618
1619 if !safety_info.is_merged_to_main {
1620 println!("\n📋 Branch status:");
1621 println!(" • Not merged to '{}'", safety_info.main_branch_name);
1622 if let Some(ref remote) = safety_info.remote_tracking_branch {
1623 println!(" • Remote tracking branch: {remote}");
1624 } else {
1625 println!(" • No remote tracking branch");
1626 }
1627 }
1628
1629 println!("\n💡 Safer alternatives:");
1630 if !safety_info.unpushed_commits.is_empty() {
1631 if let Some(ref _remote) = safety_info.remote_tracking_branch {
1632 println!(" • Push commits first: git push origin {branch_name}");
1633 } else {
1634 println!(" • Create and push to remote: git push -u origin {branch_name}");
1635 }
1636 }
1637 if !safety_info.is_merged_to_main {
1638 println!(
1639 " • Merge to {} first: git checkout {} && git merge {branch_name}",
1640 safety_info.main_branch_name, safety_info.main_branch_name
1641 );
1642 }
1643
1644 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
1645 .with_prompt("Do you want to proceed with deleting this branch?")
1646 .default(false)
1647 .interact()
1648 .map_err(|e| CascadeError::branch(format!("Failed to get user confirmation: {e}")))?;
1649
1650 if !confirmed {
1651 return Err(CascadeError::branch(
1652 "Branch deletion cancelled by user. Use --force to bypass this check.".to_string(),
1653 ));
1654 }
1655
1656 Ok(())
1657 }
1658
1659 fn detect_main_branch(&self) -> Result<String> {
1661 let main_candidates = ["main", "master", "develop", "trunk"];
1662
1663 for candidate in &main_candidates {
1664 if self
1665 .repo
1666 .find_branch(candidate, git2::BranchType::Local)
1667 .is_ok()
1668 {
1669 return Ok(candidate.to_string());
1670 }
1671 }
1672
1673 if let Ok(head) = self.repo.head() {
1675 if let Some(name) = head.shorthand() {
1676 return Ok(name.to_string());
1677 }
1678 }
1679
1680 Ok("main".to_string())
1682 }
1683
1684 fn is_branch_merged_to_main(&self, branch_name: &str, main_branch: &str) -> Result<bool> {
1686 match self.get_commits_between(main_branch, branch_name) {
1688 Ok(commits) => Ok(commits.is_empty()),
1689 Err(_) => {
1690 Ok(false)
1692 }
1693 }
1694 }
1695
1696 fn get_remote_tracking_branch(&self, branch_name: &str) -> Option<String> {
1698 let remote_candidates = [
1700 format!("origin/{branch_name}"),
1701 format!("remotes/origin/{branch_name}"),
1702 ];
1703
1704 for candidate in &remote_candidates {
1705 if self
1706 .repo
1707 .find_reference(&format!(
1708 "refs/remotes/{}",
1709 candidate.replace("remotes/", "")
1710 ))
1711 .is_ok()
1712 {
1713 return Some(candidate.clone());
1714 }
1715 }
1716
1717 None
1718 }
1719
1720 fn check_checkout_safety(&self, _target: &str) -> Result<Option<CheckoutSafety>> {
1722 let is_dirty = self.is_dirty()?;
1724 if !is_dirty {
1725 return Ok(None);
1727 }
1728
1729 let current_branch = self.get_current_branch().ok();
1731
1732 let modified_files = self.get_modified_files()?;
1734 let staged_files = self.get_staged_files()?;
1735 let untracked_files = self.get_untracked_files()?;
1736
1737 let has_uncommitted_changes = !modified_files.is_empty() || !staged_files.is_empty();
1738
1739 if has_uncommitted_changes || !untracked_files.is_empty() {
1740 return Ok(Some(CheckoutSafety {
1741 has_uncommitted_changes,
1742 modified_files,
1743 staged_files,
1744 untracked_files,
1745 stash_created: None,
1746 current_branch,
1747 }));
1748 }
1749
1750 Ok(None)
1751 }
1752
1753 fn handle_checkout_confirmation(
1755 &self,
1756 target: &str,
1757 safety_info: &CheckoutSafety,
1758 ) -> Result<()> {
1759 let is_ci = std::env::var("CI").is_ok();
1761 let no_confirm = std::env::var("CHECKOUT_NO_CONFIRM").is_ok();
1762 let is_non_interactive = is_ci || no_confirm;
1763
1764 if is_non_interactive {
1765 return Err(CascadeError::branch(
1766 format!(
1767 "Cannot checkout '{target}' with uncommitted changes in non-interactive mode. Commit your changes or use stash first."
1768 )
1769 ));
1770 }
1771
1772 println!("\n⚠️ CHECKOUT WARNING ⚠️");
1774 println!("You have uncommitted changes that could be lost:");
1775
1776 if !safety_info.modified_files.is_empty() {
1777 println!(
1778 "\n📝 Modified files ({}):",
1779 safety_info.modified_files.len()
1780 );
1781 for file in safety_info.modified_files.iter().take(10) {
1782 println!(" - {file}");
1783 }
1784 if safety_info.modified_files.len() > 10 {
1785 println!(" ... and {} more", safety_info.modified_files.len() - 10);
1786 }
1787 }
1788
1789 if !safety_info.staged_files.is_empty() {
1790 println!("\n📁 Staged files ({}):", safety_info.staged_files.len());
1791 for file in safety_info.staged_files.iter().take(10) {
1792 println!(" - {file}");
1793 }
1794 if safety_info.staged_files.len() > 10 {
1795 println!(" ... and {} more", safety_info.staged_files.len() - 10);
1796 }
1797 }
1798
1799 if !safety_info.untracked_files.is_empty() {
1800 println!(
1801 "\n❓ Untracked files ({}):",
1802 safety_info.untracked_files.len()
1803 );
1804 for file in safety_info.untracked_files.iter().take(5) {
1805 println!(" - {file}");
1806 }
1807 if safety_info.untracked_files.len() > 5 {
1808 println!(" ... and {} more", safety_info.untracked_files.len() - 5);
1809 }
1810 }
1811
1812 println!("\n🔄 Options:");
1813 println!("1. Stash changes and checkout (recommended)");
1814 println!("2. Force checkout (WILL LOSE UNCOMMITTED CHANGES)");
1815 println!("3. Cancel checkout");
1816
1817 let confirmation = Confirm::with_theme(&ColorfulTheme::default())
1818 .with_prompt("Would you like to stash your changes and proceed with checkout?")
1819 .interact()
1820 .map_err(|e| CascadeError::branch(format!("Could not get user confirmation: {e}")))?;
1821
1822 if confirmation {
1823 let stash_message = format!(
1825 "Auto-stash before checkout to {} at {}",
1826 target,
1827 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
1828 );
1829
1830 match self.create_stash(&stash_message) {
1831 Ok(stash_oid) => {
1832 println!("✅ Created stash: {stash_message} ({stash_oid})");
1833 println!("💡 You can restore with: git stash pop");
1834 }
1835 Err(e) => {
1836 println!("❌ Failed to create stash: {e}");
1837
1838 let force_confirm = Confirm::with_theme(&ColorfulTheme::default())
1839 .with_prompt("Stash failed. Force checkout anyway? (WILL LOSE CHANGES)")
1840 .interact()
1841 .map_err(|e| {
1842 CascadeError::branch(format!("Could not get confirmation: {e}"))
1843 })?;
1844
1845 if !force_confirm {
1846 return Err(CascadeError::branch(
1847 "Checkout cancelled by user".to_string(),
1848 ));
1849 }
1850 }
1851 }
1852 } else {
1853 return Err(CascadeError::branch(
1854 "Checkout cancelled by user".to_string(),
1855 ));
1856 }
1857
1858 Ok(())
1859 }
1860
1861 fn create_stash(&self, message: &str) -> Result<String> {
1863 warn!("Automatic stashing not yet implemented - please stash manually");
1867 Err(CascadeError::branch(format!(
1868 "Please manually stash your changes first: git stash push -m \"{message}\""
1869 )))
1870 }
1871
1872 fn get_modified_files(&self) -> Result<Vec<String>> {
1874 let mut opts = git2::StatusOptions::new();
1875 opts.include_untracked(false).include_ignored(false);
1876
1877 let statuses = self
1878 .repo
1879 .statuses(Some(&mut opts))
1880 .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
1881
1882 let mut modified_files = Vec::new();
1883 for status in statuses.iter() {
1884 let flags = status.status();
1885 if flags.contains(git2::Status::WT_MODIFIED) || flags.contains(git2::Status::WT_DELETED)
1886 {
1887 if let Some(path) = status.path() {
1888 modified_files.push(path.to_string());
1889 }
1890 }
1891 }
1892
1893 Ok(modified_files)
1894 }
1895
1896 fn get_staged_files(&self) -> Result<Vec<String>> {
1898 let mut opts = git2::StatusOptions::new();
1899 opts.include_untracked(false).include_ignored(false);
1900
1901 let statuses = self
1902 .repo
1903 .statuses(Some(&mut opts))
1904 .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
1905
1906 let mut staged_files = Vec::new();
1907 for status in statuses.iter() {
1908 let flags = status.status();
1909 if flags.contains(git2::Status::INDEX_MODIFIED)
1910 || flags.contains(git2::Status::INDEX_NEW)
1911 || flags.contains(git2::Status::INDEX_DELETED)
1912 {
1913 if let Some(path) = status.path() {
1914 staged_files.push(path.to_string());
1915 }
1916 }
1917 }
1918
1919 Ok(staged_files)
1920 }
1921
1922 fn count_commits_between(&self, from: &str, to: &str) -> Result<usize> {
1924 let commits = self.get_commits_between(from, to)?;
1925 Ok(commits.len())
1926 }
1927
1928 pub fn resolve_reference(&self, reference: &str) -> Result<git2::Commit<'_>> {
1930 if let Ok(oid) = Oid::from_str(reference) {
1932 if let Ok(commit) = self.repo.find_commit(oid) {
1933 return Ok(commit);
1934 }
1935 }
1936
1937 let obj = self.repo.revparse_single(reference).map_err(|e| {
1939 CascadeError::branch(format!("Could not resolve reference '{reference}': {e}"))
1940 })?;
1941
1942 obj.peel_to_commit().map_err(|e| {
1943 CascadeError::branch(format!(
1944 "Reference '{reference}' does not point to a commit: {e}"
1945 ))
1946 })
1947 }
1948
1949 pub fn reset_soft(&self, target_ref: &str) -> Result<()> {
1951 let target_commit = self.resolve_reference(target_ref)?;
1952
1953 self.repo
1954 .reset(target_commit.as_object(), git2::ResetType::Soft, None)
1955 .map_err(CascadeError::Git)?;
1956
1957 Ok(())
1958 }
1959
1960 pub fn find_branch_containing_commit(&self, commit_hash: &str) -> Result<String> {
1962 let oid = Oid::from_str(commit_hash).map_err(|e| {
1963 CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
1964 })?;
1965
1966 let branches = self
1968 .repo
1969 .branches(Some(git2::BranchType::Local))
1970 .map_err(CascadeError::Git)?;
1971
1972 for branch_result in branches {
1973 let (branch, _) = branch_result.map_err(CascadeError::Git)?;
1974
1975 if let Some(branch_name) = branch.name().map_err(CascadeError::Git)? {
1976 if let Ok(branch_head) = branch.get().peel_to_commit() {
1978 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
1980 revwalk.push(branch_head.id()).map_err(CascadeError::Git)?;
1981
1982 for commit_oid in revwalk {
1983 let commit_oid = commit_oid.map_err(CascadeError::Git)?;
1984 if commit_oid == oid {
1985 return Ok(branch_name.to_string());
1986 }
1987 }
1988 }
1989 }
1990 }
1991
1992 Err(CascadeError::branch(format!(
1994 "Commit {commit_hash} not found in any local branch"
1995 )))
1996 }
1997
1998 pub async fn fetch_async(&self) -> Result<()> {
2002 let repo_path = self.path.clone();
2003 crate::utils::async_ops::run_git_operation(move || {
2004 let repo = GitRepository::open(&repo_path)?;
2005 repo.fetch()
2006 })
2007 .await
2008 }
2009
2010 pub async fn pull_async(&self, branch: &str) -> Result<()> {
2012 let repo_path = self.path.clone();
2013 let branch_name = branch.to_string();
2014 crate::utils::async_ops::run_git_operation(move || {
2015 let repo = GitRepository::open(&repo_path)?;
2016 repo.pull(&branch_name)
2017 })
2018 .await
2019 }
2020
2021 pub async fn push_branch_async(&self, branch_name: &str) -> Result<()> {
2023 let repo_path = self.path.clone();
2024 let branch = branch_name.to_string();
2025 crate::utils::async_ops::run_git_operation(move || {
2026 let repo = GitRepository::open(&repo_path)?;
2027 repo.push(&branch)
2028 })
2029 .await
2030 }
2031
2032 pub async fn cherry_pick_commit_async(&self, commit_hash: &str) -> Result<String> {
2034 let repo_path = self.path.clone();
2035 let hash = commit_hash.to_string();
2036 crate::utils::async_ops::run_git_operation(move || {
2037 let repo = GitRepository::open(&repo_path)?;
2038 repo.cherry_pick(&hash)
2039 })
2040 .await
2041 }
2042
2043 pub async fn get_commit_hashes_between_async(
2045 &self,
2046 from: &str,
2047 to: &str,
2048 ) -> Result<Vec<String>> {
2049 let repo_path = self.path.clone();
2050 let from_str = from.to_string();
2051 let to_str = to.to_string();
2052 crate::utils::async_ops::run_git_operation(move || {
2053 let repo = GitRepository::open(&repo_path)?;
2054 let commits = repo.get_commits_between(&from_str, &to_str)?;
2055 Ok(commits.into_iter().map(|c| c.id().to_string()).collect())
2056 })
2057 .await
2058 }
2059
2060 pub fn reset_branch_to_commit(&self, branch_name: &str, commit_hash: &str) -> Result<()> {
2062 info!(
2063 "Resetting branch '{}' to commit {}",
2064 branch_name,
2065 &commit_hash[..8]
2066 );
2067
2068 let target_oid = git2::Oid::from_str(commit_hash).map_err(|e| {
2070 CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
2071 })?;
2072
2073 let _target_commit = self.repo.find_commit(target_oid).map_err(|e| {
2074 CascadeError::branch(format!("Could not find commit '{commit_hash}': {e}"))
2075 })?;
2076
2077 let _branch = self
2079 .repo
2080 .find_branch(branch_name, git2::BranchType::Local)
2081 .map_err(|e| {
2082 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
2083 })?;
2084
2085 let branch_ref_name = format!("refs/heads/{branch_name}");
2087 self.repo
2088 .reference(
2089 &branch_ref_name,
2090 target_oid,
2091 true,
2092 &format!("Reset {branch_name} to {commit_hash}"),
2093 )
2094 .map_err(|e| {
2095 CascadeError::branch(format!(
2096 "Could not reset branch '{branch_name}' to commit '{commit_hash}': {e}"
2097 ))
2098 })?;
2099
2100 tracing::info!(
2101 "Successfully reset branch '{}' to commit {}",
2102 branch_name,
2103 &commit_hash[..8]
2104 );
2105 Ok(())
2106 }
2107}
2108
2109#[cfg(test)]
2110mod tests {
2111 use super::*;
2112 use std::process::Command;
2113 use tempfile::TempDir;
2114
2115 fn create_test_repo() -> (TempDir, PathBuf) {
2116 let temp_dir = TempDir::new().unwrap();
2117 let repo_path = temp_dir.path().to_path_buf();
2118
2119 Command::new("git")
2121 .args(["init"])
2122 .current_dir(&repo_path)
2123 .output()
2124 .unwrap();
2125 Command::new("git")
2126 .args(["config", "user.name", "Test"])
2127 .current_dir(&repo_path)
2128 .output()
2129 .unwrap();
2130 Command::new("git")
2131 .args(["config", "user.email", "test@test.com"])
2132 .current_dir(&repo_path)
2133 .output()
2134 .unwrap();
2135
2136 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
2138 Command::new("git")
2139 .args(["add", "."])
2140 .current_dir(&repo_path)
2141 .output()
2142 .unwrap();
2143 Command::new("git")
2144 .args(["commit", "-m", "Initial commit"])
2145 .current_dir(&repo_path)
2146 .output()
2147 .unwrap();
2148
2149 (temp_dir, repo_path)
2150 }
2151
2152 fn create_commit(repo_path: &PathBuf, message: &str, filename: &str) {
2153 let file_path = repo_path.join(filename);
2154 std::fs::write(&file_path, format!("Content for {filename}\n")).unwrap();
2155
2156 Command::new("git")
2157 .args(["add", filename])
2158 .current_dir(repo_path)
2159 .output()
2160 .unwrap();
2161 Command::new("git")
2162 .args(["commit", "-m", message])
2163 .current_dir(repo_path)
2164 .output()
2165 .unwrap();
2166 }
2167
2168 #[test]
2169 fn test_repository_info() {
2170 let (_temp_dir, repo_path) = create_test_repo();
2171 let repo = GitRepository::open(&repo_path).unwrap();
2172
2173 let info = repo.get_info().unwrap();
2174 assert!(!info.is_dirty); assert!(
2176 info.head_branch == Some("master".to_string())
2177 || info.head_branch == Some("main".to_string()),
2178 "Expected default branch to be 'master' or 'main', got {:?}",
2179 info.head_branch
2180 );
2181 assert!(info.head_commit.is_some()); assert!(info.untracked_files.is_empty()); }
2184
2185 #[test]
2186 fn test_force_push_branch_basic() {
2187 let (_temp_dir, repo_path) = create_test_repo();
2188 let repo = GitRepository::open(&repo_path).unwrap();
2189
2190 let default_branch = repo.get_current_branch().unwrap();
2192
2193 create_commit(&repo_path, "Feature commit 1", "feature1.rs");
2195 Command::new("git")
2196 .args(["checkout", "-b", "source-branch"])
2197 .current_dir(&repo_path)
2198 .output()
2199 .unwrap();
2200 create_commit(&repo_path, "Feature commit 2", "feature2.rs");
2201
2202 Command::new("git")
2204 .args(["checkout", &default_branch])
2205 .current_dir(&repo_path)
2206 .output()
2207 .unwrap();
2208 Command::new("git")
2209 .args(["checkout", "-b", "target-branch"])
2210 .current_dir(&repo_path)
2211 .output()
2212 .unwrap();
2213 create_commit(&repo_path, "Target commit", "target.rs");
2214
2215 let result = repo.force_push_branch("target-branch", "source-branch");
2217
2218 assert!(result.is_ok() || result.is_err()); }
2222
2223 #[test]
2224 fn test_force_push_branch_nonexistent_branches() {
2225 let (_temp_dir, repo_path) = create_test_repo();
2226 let repo = GitRepository::open(&repo_path).unwrap();
2227
2228 let default_branch = repo.get_current_branch().unwrap();
2230
2231 let result = repo.force_push_branch("target", "nonexistent-source");
2233 assert!(result.is_err());
2234
2235 let result = repo.force_push_branch("nonexistent-target", &default_branch);
2237 assert!(result.is_err());
2238 }
2239
2240 #[test]
2241 fn test_force_push_workflow_simulation() {
2242 let (_temp_dir, repo_path) = create_test_repo();
2243 let repo = GitRepository::open(&repo_path).unwrap();
2244
2245 Command::new("git")
2248 .args(["checkout", "-b", "feature-auth"])
2249 .current_dir(&repo_path)
2250 .output()
2251 .unwrap();
2252 create_commit(&repo_path, "Add authentication", "auth.rs");
2253
2254 Command::new("git")
2256 .args(["checkout", "-b", "feature-auth-v2"])
2257 .current_dir(&repo_path)
2258 .output()
2259 .unwrap();
2260 create_commit(&repo_path, "Fix auth validation", "auth.rs");
2261
2262 let result = repo.force_push_branch("feature-auth", "feature-auth-v2");
2264
2265 match result {
2267 Ok(_) => {
2268 Command::new("git")
2270 .args(["checkout", "feature-auth"])
2271 .current_dir(&repo_path)
2272 .output()
2273 .unwrap();
2274 let log_output = Command::new("git")
2275 .args(["log", "--oneline", "-2"])
2276 .current_dir(&repo_path)
2277 .output()
2278 .unwrap();
2279 let log_str = String::from_utf8_lossy(&log_output.stdout);
2280 assert!(
2281 log_str.contains("Fix auth validation")
2282 || log_str.contains("Add authentication")
2283 );
2284 }
2285 Err(_) => {
2286 }
2289 }
2290 }
2291
2292 #[test]
2293 fn test_branch_operations() {
2294 let (_temp_dir, repo_path) = create_test_repo();
2295 let repo = GitRepository::open(&repo_path).unwrap();
2296
2297 let current = repo.get_current_branch().unwrap();
2299 assert!(
2300 current == "master" || current == "main",
2301 "Expected default branch to be 'master' or 'main', got '{current}'"
2302 );
2303
2304 Command::new("git")
2306 .args(["checkout", "-b", "test-branch"])
2307 .current_dir(&repo_path)
2308 .output()
2309 .unwrap();
2310 let current = repo.get_current_branch().unwrap();
2311 assert_eq!(current, "test-branch");
2312 }
2313
2314 #[test]
2315 fn test_commit_operations() {
2316 let (_temp_dir, repo_path) = create_test_repo();
2317 let repo = GitRepository::open(&repo_path).unwrap();
2318
2319 let head = repo.get_head_commit().unwrap();
2321 assert_eq!(head.message().unwrap().trim(), "Initial commit");
2322
2323 let hash = head.id().to_string();
2325 let same_commit = repo.get_commit(&hash).unwrap();
2326 assert_eq!(head.id(), same_commit.id());
2327 }
2328
2329 #[test]
2330 fn test_checkout_safety_clean_repo() {
2331 let (_temp_dir, repo_path) = create_test_repo();
2332 let repo = GitRepository::open(&repo_path).unwrap();
2333
2334 create_commit(&repo_path, "Second commit", "test.txt");
2336 Command::new("git")
2337 .args(["checkout", "-b", "test-branch"])
2338 .current_dir(&repo_path)
2339 .output()
2340 .unwrap();
2341
2342 let safety_result = repo.check_checkout_safety("main");
2344 assert!(safety_result.is_ok());
2345 assert!(safety_result.unwrap().is_none()); }
2347
2348 #[test]
2349 fn test_checkout_safety_with_modified_files() {
2350 let (_temp_dir, repo_path) = create_test_repo();
2351 let repo = GitRepository::open(&repo_path).unwrap();
2352
2353 Command::new("git")
2355 .args(["checkout", "-b", "test-branch"])
2356 .current_dir(&repo_path)
2357 .output()
2358 .unwrap();
2359
2360 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2362
2363 let safety_result = repo.check_checkout_safety("main");
2365 assert!(safety_result.is_ok());
2366 let safety_info = safety_result.unwrap();
2367 assert!(safety_info.is_some());
2368
2369 let info = safety_info.unwrap();
2370 assert!(!info.modified_files.is_empty());
2371 assert!(info.modified_files.contains(&"README.md".to_string()));
2372 }
2373
2374 #[test]
2375 fn test_unsafe_checkout_methods() {
2376 let (_temp_dir, repo_path) = create_test_repo();
2377 let repo = GitRepository::open(&repo_path).unwrap();
2378
2379 create_commit(&repo_path, "Second commit", "test.txt");
2381 Command::new("git")
2382 .args(["checkout", "-b", "test-branch"])
2383 .current_dir(&repo_path)
2384 .output()
2385 .unwrap();
2386
2387 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2389
2390 let _result = repo.checkout_branch_unsafe("master");
2392 let head_commit = repo.get_head_commit().unwrap();
2397 let commit_hash = head_commit.id().to_string();
2398 let _result = repo.checkout_commit_unsafe(&commit_hash);
2399 }
2401
2402 #[test]
2403 fn test_get_modified_files() {
2404 let (_temp_dir, repo_path) = create_test_repo();
2405 let repo = GitRepository::open(&repo_path).unwrap();
2406
2407 let modified = repo.get_modified_files().unwrap();
2409 assert!(modified.is_empty());
2410
2411 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2413
2414 let modified = repo.get_modified_files().unwrap();
2416 assert_eq!(modified.len(), 1);
2417 assert!(modified.contains(&"README.md".to_string()));
2418 }
2419
2420 #[test]
2421 fn test_get_staged_files() {
2422 let (_temp_dir, repo_path) = create_test_repo();
2423 let repo = GitRepository::open(&repo_path).unwrap();
2424
2425 let staged = repo.get_staged_files().unwrap();
2427 assert!(staged.is_empty());
2428
2429 std::fs::write(repo_path.join("staged.txt"), "Staged content").unwrap();
2431 Command::new("git")
2432 .args(["add", "staged.txt"])
2433 .current_dir(&repo_path)
2434 .output()
2435 .unwrap();
2436
2437 let staged = repo.get_staged_files().unwrap();
2439 assert_eq!(staged.len(), 1);
2440 assert!(staged.contains(&"staged.txt".to_string()));
2441 }
2442
2443 #[test]
2444 fn test_create_stash_fallback() {
2445 let (_temp_dir, repo_path) = create_test_repo();
2446 let repo = GitRepository::open(&repo_path).unwrap();
2447
2448 let result = repo.create_stash("test stash");
2450 assert!(result.is_err());
2451 let error_msg = result.unwrap_err().to_string();
2452 assert!(error_msg.contains("git stash push"));
2453 }
2454
2455 #[test]
2456 fn test_delete_branch_unsafe() {
2457 let (_temp_dir, repo_path) = create_test_repo();
2458 let repo = GitRepository::open(&repo_path).unwrap();
2459
2460 create_commit(&repo_path, "Second commit", "test.txt");
2462 Command::new("git")
2463 .args(["checkout", "-b", "test-branch"])
2464 .current_dir(&repo_path)
2465 .output()
2466 .unwrap();
2467
2468 create_commit(&repo_path, "Branch-specific commit", "branch.txt");
2470
2471 Command::new("git")
2473 .args(["checkout", "master"])
2474 .current_dir(&repo_path)
2475 .output()
2476 .unwrap();
2477
2478 let result = repo.delete_branch_unsafe("test-branch");
2481 let _ = result; }
2485
2486 #[test]
2487 fn test_force_push_unsafe() {
2488 let (_temp_dir, repo_path) = create_test_repo();
2489 let repo = GitRepository::open(&repo_path).unwrap();
2490
2491 create_commit(&repo_path, "Second commit", "test.txt");
2493 Command::new("git")
2494 .args(["checkout", "-b", "test-branch"])
2495 .current_dir(&repo_path)
2496 .output()
2497 .unwrap();
2498
2499 let _result = repo.force_push_branch_unsafe("test-branch", "test-branch");
2502 }
2504}