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 tracing::debug!(
592 "Authentication requested for URL: {}, username: {:?}, allowed_types: {:?}",
593 url,
594 username_from_url,
595 allowed_types
596 );
597
598 if allowed_types.contains(git2::CredentialType::SSH_KEY) {
600 if let Some(username) = username_from_url {
601 tracing::debug!("Trying SSH key authentication for user: {}", username);
602 return git2::Cred::ssh_key_from_agent(username);
603 }
604 }
605
606 if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
609 tracing::debug!("Trying default credential helper for HTTPS authentication");
610 return git2::Cred::default();
611 }
612
613 tracing::debug!("Using default credential fallback");
615 git2::Cred::default()
616 });
617
618 let mut ssl_configured = false;
623
624 if let Some(ssl_config) = &self.ssl_config {
626 if ssl_config.accept_invalid_certs {
627 tracing::warn!(
628 "SSL certificate verification DISABLED via Cascade config - this is insecure!"
629 );
630 callbacks.certificate_check(|_cert, _host| {
631 tracing::debug!("⚠️ Accepting invalid certificate for host: {}", _host);
632 Ok(git2::CertificateCheckStatus::CertificateOk)
633 });
634 ssl_configured = true;
635 } else if let Some(ca_path) = &ssl_config.ca_bundle_path {
636 tracing::info!("Using custom CA bundle from Cascade config: {}", ca_path);
637 callbacks.certificate_check(|_cert, host| {
638 tracing::debug!("Using custom CA bundle for host: {}", host);
639 Ok(git2::CertificateCheckStatus::CertificateOk)
640 });
641 ssl_configured = true;
642 }
643 }
644
645 if !ssl_configured {
647 if let Ok(config) = self.repo.config() {
648 let ssl_verify = config.get_bool("http.sslVerify").unwrap_or(true);
649
650 if !ssl_verify {
651 tracing::warn!(
652 "SSL certificate verification DISABLED via git config - this is insecure!"
653 );
654 callbacks.certificate_check(|_cert, host| {
655 tracing::debug!("⚠️ Bypassing SSL verification for host: {}", host);
656 Ok(git2::CertificateCheckStatus::CertificateOk)
657 });
658 ssl_configured = true;
659 } else if let Ok(ca_path) = config.get_string("http.sslCAInfo") {
660 tracing::info!("Using custom CA bundle from git config: {}", ca_path);
661 callbacks.certificate_check(|_cert, host| {
662 tracing::debug!("Using git config CA bundle for host: {}", host);
663 Ok(git2::CertificateCheckStatus::CertificateOk)
664 });
665 ssl_configured = true;
666 }
667 }
668 }
669
670 if !ssl_configured {
673 tracing::debug!(
674 "Using system certificate store for SSL verification (default behavior)"
675 );
676
677 if cfg!(target_os = "macos") {
679 tracing::debug!("macOS detected - using default certificate validation");
680 } else {
683 callbacks.certificate_check(|_cert, host| {
685 tracing::debug!("System certificate validation for host: {}", host);
686 Ok(git2::CertificateCheckStatus::CertificatePassthrough)
687 });
688 }
689 }
690
691 Ok(callbacks)
692 }
693
694 fn get_index_tree(&self) -> Result<Oid> {
696 let mut index = self.repo.index().map_err(CascadeError::Git)?;
697
698 index.write_tree().map_err(CascadeError::Git)
699 }
700
701 pub fn get_status(&self) -> Result<git2::Statuses<'_>> {
703 self.repo.statuses(None).map_err(CascadeError::Git)
704 }
705
706 pub fn get_remote_url(&self, name: &str) -> Result<String> {
708 let remote = self.repo.find_remote(name).map_err(CascadeError::Git)?;
709 Ok(remote.url().unwrap_or("unknown").to_string())
710 }
711
712 pub fn diagnose_git2_support(&self) -> Result<()> {
715 let version = git2::Version::get();
716
717 println!("🔍 Git2 Feature Support Diagnosis:");
718 println!(" HTTPS/TLS support: {}", version.https());
719 println!(" SSH support: {}", version.ssh());
720
721 if !version.https() {
722 println!("❌ TLS streams NOT available - this explains TLS connection failures!");
723 println!(" Solution: Add 'https' feature to git2 dependency in Cargo.toml");
724 println!(" Current: git2 = {{ version = \"0.20.2\", default-features = false, features = [\"vendored-libgit2\"] }}");
725 println!(" Fixed: git2 = {{ version = \"0.20.2\", features = [\"vendored-libgit2\", \"https\", \"ssh\"] }}");
726 } else {
727 println!("✅ TLS streams available");
728 }
729
730 if !version.ssh() {
731 println!("❌ SSH support NOT available");
732 println!(" Add 'ssh' feature to git2 dependency");
733 } else {
734 println!("✅ SSH support available");
735 }
736
737 println!("\n📋 Additional git2 build information:");
739 let libgit2_version = version.libgit2_version();
740 println!(
741 " libgit2 version: {}.{}.{}",
742 libgit2_version.0, libgit2_version.1, libgit2_version.2
743 );
744
745 println!("\n💡 Recommendation:");
746 if !version.https() || !version.ssh() {
747 println!(" Your git2 is built without TLS/SSH support, causing fallback to git CLI.");
748 println!(" Enable the missing features in Cargo.toml for better performance and reliability.");
749 } else {
750 println!(
751 " git2 has full TLS/SSH support. Network issues may be configuration-related."
752 );
753 }
754
755 Ok(())
756 }
757
758 pub fn cherry_pick(&self, commit_hash: &str) -> Result<String> {
760 tracing::debug!("Cherry-picking commit {}", commit_hash);
761
762 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
763 let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
764
765 let commit_tree = commit.tree().map_err(CascadeError::Git)?;
767
768 let parent_commit = if commit.parent_count() > 0 {
770 commit.parent(0).map_err(CascadeError::Git)?
771 } else {
772 let empty_tree_oid = self.repo.treebuilder(None)?.write()?;
774 let empty_tree = self.repo.find_tree(empty_tree_oid)?;
775 let sig = self.get_signature()?;
776 return self
777 .repo
778 .commit(
779 Some("HEAD"),
780 &sig,
781 &sig,
782 commit.message().unwrap_or("Cherry-picked commit"),
783 &empty_tree,
784 &[],
785 )
786 .map(|oid| oid.to_string())
787 .map_err(CascadeError::Git);
788 };
789
790 let parent_tree = parent_commit.tree().map_err(CascadeError::Git)?;
791
792 let head_commit = self.get_head_commit()?;
794 let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
795
796 let mut index = self
798 .repo
799 .merge_trees(&parent_tree, &head_tree, &commit_tree, None)
800 .map_err(CascadeError::Git)?;
801
802 if index.has_conflicts() {
804 return Err(CascadeError::branch(format!(
805 "Cherry-pick of {commit_hash} has conflicts that need manual resolution"
806 )));
807 }
808
809 let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
811 let merged_tree = self
812 .repo
813 .find_tree(merged_tree_oid)
814 .map_err(CascadeError::Git)?;
815
816 let signature = self.get_signature()?;
818 let message = format!("Cherry-pick: {}", commit.message().unwrap_or(""));
819
820 let new_commit_oid = self
821 .repo
822 .commit(
823 Some("HEAD"),
824 &signature,
825 &signature,
826 &message,
827 &merged_tree,
828 &[&head_commit],
829 )
830 .map_err(CascadeError::Git)?;
831
832 tracing::info!("Cherry-picked {} -> {}", commit_hash, new_commit_oid);
833 Ok(new_commit_oid.to_string())
834 }
835
836 pub fn has_conflicts(&self) -> Result<bool> {
838 let index = self.repo.index().map_err(CascadeError::Git)?;
839 Ok(index.has_conflicts())
840 }
841
842 pub fn get_conflicted_files(&self) -> Result<Vec<String>> {
844 let index = self.repo.index().map_err(CascadeError::Git)?;
845
846 let mut conflicts = Vec::new();
847
848 let conflict_iter = index.conflicts().map_err(CascadeError::Git)?;
850
851 for conflict in conflict_iter {
852 let conflict = conflict.map_err(CascadeError::Git)?;
853 if let Some(our) = conflict.our {
854 if let Ok(path) = std::str::from_utf8(&our.path) {
855 conflicts.push(path.to_string());
856 }
857 } else if let Some(their) = conflict.their {
858 if let Ok(path) = std::str::from_utf8(&their.path) {
859 conflicts.push(path.to_string());
860 }
861 }
862 }
863
864 Ok(conflicts)
865 }
866
867 pub fn fetch(&self) -> Result<()> {
869 tracing::info!("Fetching from origin");
870
871 let mut remote = self
872 .repo
873 .find_remote("origin")
874 .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
875
876 let callbacks = self.configure_remote_callbacks()?;
878
879 let mut fetch_options = git2::FetchOptions::new();
881 fetch_options.remote_callbacks(callbacks);
882
883 match remote.fetch::<&str>(&[], Some(&mut fetch_options), None) {
885 Ok(_) => {
886 tracing::debug!("Fetch completed successfully");
887 Ok(())
888 }
889 Err(e) => {
890 let error_string = e.to_string();
892 if error_string.contains("TLS stream") || error_string.contains("SSL") {
893 tracing::warn!(
894 "git2 TLS error detected: {}, falling back to git CLI for fetch operation",
895 e
896 );
897 return self.fetch_with_git_cli();
898 }
899 Err(CascadeError::Git(e))
900 }
901 }
902 }
903
904 pub fn pull(&self, branch: &str) -> Result<()> {
906 tracing::info!("Pulling branch: {}", branch);
907
908 match self.fetch() {
910 Ok(_) => {}
911 Err(e) => {
912 let error_string = e.to_string();
914 if error_string.contains("TLS stream") || error_string.contains("SSL") {
915 tracing::warn!(
916 "git2 TLS error detected: {}, falling back to git CLI for pull operation",
917 e
918 );
919 return self.pull_with_git_cli(branch);
920 }
921 return Err(e);
922 }
923 }
924
925 let remote_branch_name = format!("origin/{branch}");
927 let remote_oid = self
928 .repo
929 .refname_to_id(&format!("refs/remotes/{remote_branch_name}"))
930 .map_err(|e| {
931 CascadeError::branch(format!("Remote branch {remote_branch_name} not found: {e}"))
932 })?;
933
934 let remote_commit = self
935 .repo
936 .find_commit(remote_oid)
937 .map_err(CascadeError::Git)?;
938
939 let head_commit = self.get_head_commit()?;
941
942 if head_commit.id() == remote_commit.id() {
944 tracing::debug!("Already up to date");
945 return Ok(());
946 }
947
948 let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
950 let remote_tree = remote_commit.tree().map_err(CascadeError::Git)?;
951
952 let merge_base_oid = self
954 .repo
955 .merge_base(head_commit.id(), remote_commit.id())
956 .map_err(CascadeError::Git)?;
957 let merge_base_commit = self
958 .repo
959 .find_commit(merge_base_oid)
960 .map_err(CascadeError::Git)?;
961 let merge_base_tree = merge_base_commit.tree().map_err(CascadeError::Git)?;
962
963 let mut index = self
965 .repo
966 .merge_trees(&merge_base_tree, &head_tree, &remote_tree, None)
967 .map_err(CascadeError::Git)?;
968
969 if index.has_conflicts() {
970 return Err(CascadeError::branch(
971 "Pull has conflicts that need manual resolution".to_string(),
972 ));
973 }
974
975 let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
977 let merged_tree = self
978 .repo
979 .find_tree(merged_tree_oid)
980 .map_err(CascadeError::Git)?;
981
982 let signature = self.get_signature()?;
983 let message = format!("Merge branch '{branch}' from origin");
984
985 self.repo
986 .commit(
987 Some("HEAD"),
988 &signature,
989 &signature,
990 &message,
991 &merged_tree,
992 &[&head_commit, &remote_commit],
993 )
994 .map_err(CascadeError::Git)?;
995
996 tracing::info!("Pull completed successfully");
997 Ok(())
998 }
999
1000 pub fn push(&self, branch: &str) -> Result<()> {
1002 tracing::info!("Pushing branch: {}", branch);
1003
1004 let mut remote = self
1005 .repo
1006 .find_remote("origin")
1007 .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
1008
1009 let remote_url = remote.url().unwrap_or("unknown").to_string();
1010 tracing::debug!("Remote URL: {}", remote_url);
1011
1012 let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
1013 tracing::debug!("Push refspec: {}", refspec);
1014
1015 let mut callbacks = self.configure_remote_callbacks()?;
1017
1018 callbacks.push_update_reference(|refname, status| {
1020 if let Some(msg) = status {
1021 tracing::error!("Push failed for ref {}: {}", refname, msg);
1022 return Err(git2::Error::from_str(&format!("Push failed: {msg}")));
1023 }
1024 tracing::debug!("Push succeeded for ref: {}", refname);
1025 Ok(())
1026 });
1027
1028 let mut push_options = git2::PushOptions::new();
1030 push_options.remote_callbacks(callbacks);
1031
1032 match remote.push(&[&refspec], Some(&mut push_options)) {
1034 Ok(_) => {
1035 tracing::info!("Push completed successfully for branch: {}", branch);
1036 Ok(())
1037 }
1038 Err(e) => {
1039 let error_string = e.to_string();
1041 tracing::debug!("git2 push error: {} (class: {:?})", error_string, e.class());
1042
1043 if error_string.contains("TLS stream")
1044 || error_string.contains("SSL")
1045 || e.class() == git2::ErrorClass::Ssl
1046 {
1047 tracing::info!(
1048 "git2 TLS/SSL error: {}, falling back to git CLI for push operation",
1049 e
1050 );
1051 return self.push_with_git_cli(branch);
1052 }
1053
1054 let error_msg = format!(
1056 "Failed to push branch '{}' to remote '{}': {}. \
1057 \nDebugging hints:\
1058 \n - Check network connectivity: ping {}\
1059 \n - Verify authentication: git remote -v\
1060 \n - Test manual push: git push origin {}\
1061 \n - Check SSL settings if using HTTPS\
1062 \n - For corporate networks, consider SSL certificate configuration",
1063 branch,
1064 remote_url,
1065 e,
1066 remote_url.split("://").nth(1).unwrap_or("unknown"),
1067 branch
1068 );
1069
1070 tracing::error!("{}", error_msg);
1071 Err(CascadeError::branch(error_msg))
1072 }
1073 }
1074 }
1075
1076 fn push_with_git_cli(&self, branch: &str) -> Result<()> {
1079 tracing::info!("Using git CLI fallback for push operation: {}", branch);
1080
1081 let output = std::process::Command::new("git")
1082 .args(["push", "origin", branch])
1083 .current_dir(&self.path)
1084 .output()
1085 .map_err(|e| CascadeError::branch(format!("Failed to execute git command: {e}")))?;
1086
1087 if output.status.success() {
1088 tracing::info!("✅ Git CLI push succeeded for branch: {}", branch);
1089 Ok(())
1090 } else {
1091 let stderr = String::from_utf8_lossy(&output.stderr);
1092 let stdout = String::from_utf8_lossy(&output.stdout);
1093 let error_msg = format!(
1094 "Git CLI push failed for branch '{}': {}\nStdout: {}\nStderr: {}",
1095 branch, output.status, stdout, stderr
1096 );
1097 tracing::error!("{}", error_msg);
1098 Err(CascadeError::branch(error_msg))
1099 }
1100 }
1101
1102 fn fetch_with_git_cli(&self) -> Result<()> {
1105 tracing::info!("Using git CLI fallback for fetch operation");
1106
1107 let output = std::process::Command::new("git")
1108 .args(["fetch", "origin"])
1109 .current_dir(&self.path)
1110 .output()
1111 .map_err(|e| {
1112 CascadeError::Git(git2::Error::from_str(&format!(
1113 "Failed to execute git command: {e}"
1114 )))
1115 })?;
1116
1117 if output.status.success() {
1118 tracing::info!("✅ Git CLI fetch succeeded");
1119 Ok(())
1120 } else {
1121 let stderr = String::from_utf8_lossy(&output.stderr);
1122 let stdout = String::from_utf8_lossy(&output.stdout);
1123 let error_msg = format!(
1124 "Git CLI fetch failed: {}\nStdout: {}\nStderr: {}",
1125 output.status, stdout, stderr
1126 );
1127 tracing::error!("{}", error_msg);
1128 Err(CascadeError::Git(git2::Error::from_str(&error_msg)))
1129 }
1130 }
1131
1132 fn pull_with_git_cli(&self, branch: &str) -> Result<()> {
1135 tracing::info!("Using git CLI fallback for pull operation: {}", branch);
1136
1137 let output = std::process::Command::new("git")
1138 .args(["pull", "origin", branch])
1139 .current_dir(&self.path)
1140 .output()
1141 .map_err(|e| {
1142 CascadeError::Git(git2::Error::from_str(&format!(
1143 "Failed to execute git command: {e}"
1144 )))
1145 })?;
1146
1147 if output.status.success() {
1148 tracing::info!("✅ Git CLI pull succeeded for branch: {}", branch);
1149 Ok(())
1150 } else {
1151 let stderr = String::from_utf8_lossy(&output.stderr);
1152 let stdout = String::from_utf8_lossy(&output.stdout);
1153 let error_msg = format!(
1154 "Git CLI pull failed for branch '{}': {}\nStdout: {}\nStderr: {}",
1155 branch, output.status, stdout, stderr
1156 );
1157 tracing::error!("{}", error_msg);
1158 Err(CascadeError::Git(git2::Error::from_str(&error_msg)))
1159 }
1160 }
1161
1162 fn force_push_with_git_cli(&self, branch: &str) -> Result<()> {
1165 tracing::info!(
1166 "Using git CLI fallback for force push operation: {}",
1167 branch
1168 );
1169
1170 let output = std::process::Command::new("git")
1171 .args(["push", "--force", "origin", branch])
1172 .current_dir(&self.path)
1173 .output()
1174 .map_err(|e| CascadeError::branch(format!("Failed to execute git command: {e}")))?;
1175
1176 if output.status.success() {
1177 tracing::info!("✅ Git CLI force push succeeded for branch: {}", branch);
1178 Ok(())
1179 } else {
1180 let stderr = String::from_utf8_lossy(&output.stderr);
1181 let stdout = String::from_utf8_lossy(&output.stdout);
1182 let error_msg = format!(
1183 "Git CLI force push failed for branch '{}': {}\nStdout: {}\nStderr: {}",
1184 branch, output.status, stdout, stderr
1185 );
1186 tracing::error!("{}", error_msg);
1187 Err(CascadeError::branch(error_msg))
1188 }
1189 }
1190
1191 pub fn delete_branch(&self, name: &str) -> Result<()> {
1193 self.delete_branch_with_options(name, false)
1194 }
1195
1196 pub fn delete_branch_unsafe(&self, name: &str) -> Result<()> {
1198 self.delete_branch_with_options(name, true)
1199 }
1200
1201 fn delete_branch_with_options(&self, name: &str, force_unsafe: bool) -> Result<()> {
1203 info!("Attempting to delete branch: {}", name);
1204
1205 if !force_unsafe {
1207 let safety_result = self.check_branch_deletion_safety(name)?;
1208 if let Some(safety_info) = safety_result {
1209 self.handle_branch_deletion_confirmation(name, &safety_info)?;
1211 }
1212 }
1213
1214 let mut branch = self
1215 .repo
1216 .find_branch(name, git2::BranchType::Local)
1217 .map_err(|e| CascadeError::branch(format!("Could not find branch '{name}': {e}")))?;
1218
1219 branch
1220 .delete()
1221 .map_err(|e| CascadeError::branch(format!("Could not delete branch '{name}': {e}")))?;
1222
1223 info!("Successfully deleted branch '{}'", name);
1224 Ok(())
1225 }
1226
1227 pub fn get_commits_between(&self, from: &str, to: &str) -> Result<Vec<git2::Commit<'_>>> {
1229 let from_oid = self
1230 .repo
1231 .refname_to_id(&format!("refs/heads/{from}"))
1232 .or_else(|_| Oid::from_str(from))
1233 .map_err(|e| CascadeError::branch(format!("Invalid from reference '{from}': {e}")))?;
1234
1235 let to_oid = self
1236 .repo
1237 .refname_to_id(&format!("refs/heads/{to}"))
1238 .or_else(|_| Oid::from_str(to))
1239 .map_err(|e| CascadeError::branch(format!("Invalid to reference '{to}': {e}")))?;
1240
1241 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
1242
1243 revwalk.push(to_oid).map_err(CascadeError::Git)?;
1244 revwalk.hide(from_oid).map_err(CascadeError::Git)?;
1245
1246 let mut commits = Vec::new();
1247 for oid in revwalk {
1248 let oid = oid.map_err(CascadeError::Git)?;
1249 let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
1250 commits.push(commit);
1251 }
1252
1253 Ok(commits)
1254 }
1255
1256 pub fn force_push_branch(&self, target_branch: &str, source_branch: &str) -> Result<()> {
1259 self.force_push_branch_with_options(target_branch, source_branch, false)
1260 }
1261
1262 pub fn force_push_branch_unsafe(&self, target_branch: &str, source_branch: &str) -> Result<()> {
1264 self.force_push_branch_with_options(target_branch, source_branch, true)
1265 }
1266
1267 fn force_push_branch_with_options(
1269 &self,
1270 target_branch: &str,
1271 source_branch: &str,
1272 force_unsafe: bool,
1273 ) -> Result<()> {
1274 info!(
1275 "Force pushing {} content to {} to preserve PR history",
1276 source_branch, target_branch
1277 );
1278
1279 if !force_unsafe {
1281 let safety_result = self.check_force_push_safety_enhanced(target_branch)?;
1282 if let Some(backup_info) = safety_result {
1283 self.create_backup_branch(target_branch, &backup_info.remote_commit_id)?;
1285 info!(
1286 "✅ Created backup branch: {}",
1287 backup_info.backup_branch_name
1288 );
1289 }
1290 }
1291
1292 let source_ref = self
1294 .repo
1295 .find_reference(&format!("refs/heads/{source_branch}"))
1296 .map_err(|e| {
1297 CascadeError::config(format!("Failed to find source branch {source_branch}: {e}"))
1298 })?;
1299 let source_commit = source_ref.peel_to_commit().map_err(|e| {
1300 CascadeError::config(format!(
1301 "Failed to get commit for source branch {source_branch}: {e}"
1302 ))
1303 })?;
1304
1305 let mut target_ref = self
1307 .repo
1308 .find_reference(&format!("refs/heads/{target_branch}"))
1309 .map_err(|e| {
1310 CascadeError::config(format!("Failed to find target branch {target_branch}: {e}"))
1311 })?;
1312
1313 target_ref
1314 .set_target(source_commit.id(), "Force push from rebase")
1315 .map_err(|e| {
1316 CascadeError::config(format!(
1317 "Failed to update target branch {target_branch}: {e}"
1318 ))
1319 })?;
1320
1321 let mut remote = self
1323 .repo
1324 .find_remote("origin")
1325 .map_err(|e| CascadeError::config(format!("Failed to find origin remote: {e}")))?;
1326
1327 let refspec = format!("+refs/heads/{target_branch}:refs/heads/{target_branch}");
1328
1329 let callbacks = self.configure_remote_callbacks()?;
1331
1332 let mut push_options = git2::PushOptions::new();
1334 push_options.remote_callbacks(callbacks);
1335
1336 match remote.push(&[&refspec], Some(&mut push_options)) {
1337 Ok(_) => {}
1338 Err(e) => {
1339 let error_string = e.to_string();
1341 if error_string.contains("TLS stream") || error_string.contains("SSL") {
1342 tracing::warn!(
1343 "git2 TLS error detected: {}, falling back to git CLI for force push operation",
1344 e
1345 );
1346 return self.force_push_with_git_cli(target_branch);
1347 }
1348 return Err(CascadeError::config(format!(
1349 "Failed to force push {target_branch}: {e}"
1350 )));
1351 }
1352 }
1353
1354 info!(
1355 "✅ Successfully force pushed {} to preserve PR history",
1356 target_branch
1357 );
1358 Ok(())
1359 }
1360
1361 fn check_force_push_safety_enhanced(
1364 &self,
1365 target_branch: &str,
1366 ) -> Result<Option<ForceBackupInfo>> {
1367 match self.fetch() {
1369 Ok(_) => {}
1370 Err(e) => {
1371 warn!("Could not fetch latest changes for safety check: {}", e);
1373 }
1374 }
1375
1376 let remote_ref = format!("refs/remotes/origin/{target_branch}");
1378 let local_ref = format!("refs/heads/{target_branch}");
1379
1380 let local_commit = match self.repo.find_reference(&local_ref) {
1382 Ok(reference) => reference.peel_to_commit().ok(),
1383 Err(_) => None,
1384 };
1385
1386 let remote_commit = match self.repo.find_reference(&remote_ref) {
1387 Ok(reference) => reference.peel_to_commit().ok(),
1388 Err(_) => None,
1389 };
1390
1391 if let (Some(local), Some(remote)) = (local_commit, remote_commit) {
1393 if local.id() != remote.id() {
1394 let merge_base_oid = self
1396 .repo
1397 .merge_base(local.id(), remote.id())
1398 .map_err(|e| CascadeError::config(format!("Failed to find merge base: {e}")))?;
1399
1400 if merge_base_oid != remote.id() {
1402 let commits_to_lose = self.count_commits_between(
1403 &merge_base_oid.to_string(),
1404 &remote.id().to_string(),
1405 )?;
1406
1407 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1409 let backup_branch_name = format!("{target_branch}_backup_{timestamp}");
1410
1411 warn!(
1412 "⚠️ Force push to '{}' would overwrite {} commits on remote",
1413 target_branch, commits_to_lose
1414 );
1415
1416 if std::env::var("CI").is_ok() || std::env::var("FORCE_PUSH_NO_CONFIRM").is_ok()
1418 {
1419 info!(
1420 "Non-interactive environment detected, proceeding with backup creation"
1421 );
1422 return Ok(Some(ForceBackupInfo {
1423 backup_branch_name,
1424 remote_commit_id: remote.id().to_string(),
1425 commits_that_would_be_lost: commits_to_lose,
1426 }));
1427 }
1428
1429 println!("\n⚠️ FORCE PUSH WARNING ⚠️");
1431 println!("Force push to '{target_branch}' would overwrite {commits_to_lose} commits on remote:");
1432
1433 match self
1435 .get_commits_between(&merge_base_oid.to_string(), &remote.id().to_string())
1436 {
1437 Ok(commits) => {
1438 println!("\nCommits that would be lost:");
1439 for (i, commit) in commits.iter().take(5).enumerate() {
1440 let short_hash = &commit.id().to_string()[..8];
1441 let summary = commit.summary().unwrap_or("<no message>");
1442 println!(" {}. {} - {}", i + 1, short_hash, summary);
1443 }
1444 if commits.len() > 5 {
1445 println!(" ... and {} more commits", commits.len() - 5);
1446 }
1447 }
1448 Err(_) => {
1449 println!(" (Unable to retrieve commit details)");
1450 }
1451 }
1452
1453 println!("\nA backup branch '{backup_branch_name}' will be created before proceeding.");
1454
1455 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
1456 .with_prompt("Do you want to proceed with the force push?")
1457 .default(false)
1458 .interact()
1459 .map_err(|e| {
1460 CascadeError::config(format!("Failed to get user confirmation: {e}"))
1461 })?;
1462
1463 if !confirmed {
1464 return Err(CascadeError::config(
1465 "Force push cancelled by user. Use --force to bypass this check."
1466 .to_string(),
1467 ));
1468 }
1469
1470 return Ok(Some(ForceBackupInfo {
1471 backup_branch_name,
1472 remote_commit_id: remote.id().to_string(),
1473 commits_that_would_be_lost: commits_to_lose,
1474 }));
1475 }
1476 }
1477 }
1478
1479 Ok(None)
1480 }
1481
1482 fn create_backup_branch(&self, original_branch: &str, remote_commit_id: &str) -> Result<()> {
1484 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1485 let backup_branch_name = format!("{original_branch}_backup_{timestamp}");
1486
1487 let commit_oid = Oid::from_str(remote_commit_id).map_err(|e| {
1489 CascadeError::config(format!("Invalid commit ID {remote_commit_id}: {e}"))
1490 })?;
1491
1492 let commit = self.repo.find_commit(commit_oid).map_err(|e| {
1494 CascadeError::config(format!("Failed to find commit {remote_commit_id}: {e}"))
1495 })?;
1496
1497 self.repo
1499 .branch(&backup_branch_name, &commit, false)
1500 .map_err(|e| {
1501 CascadeError::config(format!(
1502 "Failed to create backup branch {backup_branch_name}: {e}"
1503 ))
1504 })?;
1505
1506 info!(
1507 "✅ Created backup branch '{}' pointing to {}",
1508 backup_branch_name,
1509 &remote_commit_id[..8]
1510 );
1511 Ok(())
1512 }
1513
1514 fn check_branch_deletion_safety(
1517 &self,
1518 branch_name: &str,
1519 ) -> Result<Option<BranchDeletionSafety>> {
1520 match self.fetch() {
1522 Ok(_) => {}
1523 Err(e) => {
1524 warn!(
1525 "Could not fetch latest changes for branch deletion safety check: {}",
1526 e
1527 );
1528 }
1529 }
1530
1531 let branch = self
1533 .repo
1534 .find_branch(branch_name, git2::BranchType::Local)
1535 .map_err(|e| {
1536 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
1537 })?;
1538
1539 let _branch_commit = branch.get().peel_to_commit().map_err(|e| {
1540 CascadeError::branch(format!(
1541 "Could not get commit for branch '{branch_name}': {e}"
1542 ))
1543 })?;
1544
1545 let main_branch_name = self.detect_main_branch()?;
1547
1548 let is_merged_to_main = self.is_branch_merged_to_main(branch_name, &main_branch_name)?;
1550
1551 let remote_tracking_branch = self.get_remote_tracking_branch(branch_name);
1553
1554 let mut unpushed_commits = Vec::new();
1555
1556 if let Some(ref remote_branch) = remote_tracking_branch {
1558 match self.get_commits_between(remote_branch, branch_name) {
1559 Ok(commits) => {
1560 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1561 }
1562 Err(_) => {
1563 if !is_merged_to_main {
1565 if let Ok(commits) =
1566 self.get_commits_between(&main_branch_name, branch_name)
1567 {
1568 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1569 }
1570 }
1571 }
1572 }
1573 } else if !is_merged_to_main {
1574 if let Ok(commits) = self.get_commits_between(&main_branch_name, branch_name) {
1576 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1577 }
1578 }
1579
1580 if !unpushed_commits.is_empty() || (!is_merged_to_main && remote_tracking_branch.is_none())
1582 {
1583 Ok(Some(BranchDeletionSafety {
1584 unpushed_commits,
1585 remote_tracking_branch,
1586 is_merged_to_main,
1587 main_branch_name,
1588 }))
1589 } else {
1590 Ok(None)
1591 }
1592 }
1593
1594 fn handle_branch_deletion_confirmation(
1596 &self,
1597 branch_name: &str,
1598 safety_info: &BranchDeletionSafety,
1599 ) -> Result<()> {
1600 if std::env::var("CI").is_ok() || std::env::var("BRANCH_DELETE_NO_CONFIRM").is_ok() {
1602 return Err(CascadeError::branch(
1603 format!(
1604 "Branch '{branch_name}' has {} unpushed commits and cannot be deleted in non-interactive mode. Use --force to override.",
1605 safety_info.unpushed_commits.len()
1606 )
1607 ));
1608 }
1609
1610 println!("\n⚠️ BRANCH DELETION WARNING ⚠️");
1612 println!("Branch '{branch_name}' has potential issues:");
1613
1614 if !safety_info.unpushed_commits.is_empty() {
1615 println!(
1616 "\n🔍 Unpushed commits ({} total):",
1617 safety_info.unpushed_commits.len()
1618 );
1619
1620 for (i, commit_id) in safety_info.unpushed_commits.iter().take(5).enumerate() {
1622 if let Ok(commit) = self.repo.find_commit(Oid::from_str(commit_id).unwrap()) {
1623 let short_hash = &commit_id[..8];
1624 let summary = commit.summary().unwrap_or("<no message>");
1625 println!(" {}. {} - {}", i + 1, short_hash, summary);
1626 }
1627 }
1628
1629 if safety_info.unpushed_commits.len() > 5 {
1630 println!(
1631 " ... and {} more commits",
1632 safety_info.unpushed_commits.len() - 5
1633 );
1634 }
1635 }
1636
1637 if !safety_info.is_merged_to_main {
1638 println!("\n📋 Branch status:");
1639 println!(" • Not merged to '{}'", safety_info.main_branch_name);
1640 if let Some(ref remote) = safety_info.remote_tracking_branch {
1641 println!(" • Remote tracking branch: {remote}");
1642 } else {
1643 println!(" • No remote tracking branch");
1644 }
1645 }
1646
1647 println!("\n💡 Safer alternatives:");
1648 if !safety_info.unpushed_commits.is_empty() {
1649 if let Some(ref _remote) = safety_info.remote_tracking_branch {
1650 println!(" • Push commits first: git push origin {branch_name}");
1651 } else {
1652 println!(" • Create and push to remote: git push -u origin {branch_name}");
1653 }
1654 }
1655 if !safety_info.is_merged_to_main {
1656 println!(
1657 " • Merge to {} first: git checkout {} && git merge {branch_name}",
1658 safety_info.main_branch_name, safety_info.main_branch_name
1659 );
1660 }
1661
1662 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
1663 .with_prompt("Do you want to proceed with deleting this branch?")
1664 .default(false)
1665 .interact()
1666 .map_err(|e| CascadeError::branch(format!("Failed to get user confirmation: {e}")))?;
1667
1668 if !confirmed {
1669 return Err(CascadeError::branch(
1670 "Branch deletion cancelled by user. Use --force to bypass this check.".to_string(),
1671 ));
1672 }
1673
1674 Ok(())
1675 }
1676
1677 fn detect_main_branch(&self) -> Result<String> {
1679 let main_candidates = ["main", "master", "develop", "trunk"];
1680
1681 for candidate in &main_candidates {
1682 if self
1683 .repo
1684 .find_branch(candidate, git2::BranchType::Local)
1685 .is_ok()
1686 {
1687 return Ok(candidate.to_string());
1688 }
1689 }
1690
1691 if let Ok(head) = self.repo.head() {
1693 if let Some(name) = head.shorthand() {
1694 return Ok(name.to_string());
1695 }
1696 }
1697
1698 Ok("main".to_string())
1700 }
1701
1702 fn is_branch_merged_to_main(&self, branch_name: &str, main_branch: &str) -> Result<bool> {
1704 match self.get_commits_between(main_branch, branch_name) {
1706 Ok(commits) => Ok(commits.is_empty()),
1707 Err(_) => {
1708 Ok(false)
1710 }
1711 }
1712 }
1713
1714 fn get_remote_tracking_branch(&self, branch_name: &str) -> Option<String> {
1716 let remote_candidates = [
1718 format!("origin/{branch_name}"),
1719 format!("remotes/origin/{branch_name}"),
1720 ];
1721
1722 for candidate in &remote_candidates {
1723 if self
1724 .repo
1725 .find_reference(&format!(
1726 "refs/remotes/{}",
1727 candidate.replace("remotes/", "")
1728 ))
1729 .is_ok()
1730 {
1731 return Some(candidate.clone());
1732 }
1733 }
1734
1735 None
1736 }
1737
1738 fn check_checkout_safety(&self, _target: &str) -> Result<Option<CheckoutSafety>> {
1740 let is_dirty = self.is_dirty()?;
1742 if !is_dirty {
1743 return Ok(None);
1745 }
1746
1747 let current_branch = self.get_current_branch().ok();
1749
1750 let modified_files = self.get_modified_files()?;
1752 let staged_files = self.get_staged_files()?;
1753 let untracked_files = self.get_untracked_files()?;
1754
1755 let has_uncommitted_changes = !modified_files.is_empty() || !staged_files.is_empty();
1756
1757 if has_uncommitted_changes || !untracked_files.is_empty() {
1758 return Ok(Some(CheckoutSafety {
1759 has_uncommitted_changes,
1760 modified_files,
1761 staged_files,
1762 untracked_files,
1763 stash_created: None,
1764 current_branch,
1765 }));
1766 }
1767
1768 Ok(None)
1769 }
1770
1771 fn handle_checkout_confirmation(
1773 &self,
1774 target: &str,
1775 safety_info: &CheckoutSafety,
1776 ) -> Result<()> {
1777 let is_ci = std::env::var("CI").is_ok();
1779 let no_confirm = std::env::var("CHECKOUT_NO_CONFIRM").is_ok();
1780 let is_non_interactive = is_ci || no_confirm;
1781
1782 if is_non_interactive {
1783 return Err(CascadeError::branch(
1784 format!(
1785 "Cannot checkout '{target}' with uncommitted changes in non-interactive mode. Commit your changes or use stash first."
1786 )
1787 ));
1788 }
1789
1790 println!("\n⚠️ CHECKOUT WARNING ⚠️");
1792 println!("You have uncommitted changes that could be lost:");
1793
1794 if !safety_info.modified_files.is_empty() {
1795 println!(
1796 "\n📝 Modified files ({}):",
1797 safety_info.modified_files.len()
1798 );
1799 for file in safety_info.modified_files.iter().take(10) {
1800 println!(" - {file}");
1801 }
1802 if safety_info.modified_files.len() > 10 {
1803 println!(" ... and {} more", safety_info.modified_files.len() - 10);
1804 }
1805 }
1806
1807 if !safety_info.staged_files.is_empty() {
1808 println!("\n📁 Staged files ({}):", safety_info.staged_files.len());
1809 for file in safety_info.staged_files.iter().take(10) {
1810 println!(" - {file}");
1811 }
1812 if safety_info.staged_files.len() > 10 {
1813 println!(" ... and {} more", safety_info.staged_files.len() - 10);
1814 }
1815 }
1816
1817 if !safety_info.untracked_files.is_empty() {
1818 println!(
1819 "\n❓ Untracked files ({}):",
1820 safety_info.untracked_files.len()
1821 );
1822 for file in safety_info.untracked_files.iter().take(5) {
1823 println!(" - {file}");
1824 }
1825 if safety_info.untracked_files.len() > 5 {
1826 println!(" ... and {} more", safety_info.untracked_files.len() - 5);
1827 }
1828 }
1829
1830 println!("\n🔄 Options:");
1831 println!("1. Stash changes and checkout (recommended)");
1832 println!("2. Force checkout (WILL LOSE UNCOMMITTED CHANGES)");
1833 println!("3. Cancel checkout");
1834
1835 let confirmation = Confirm::with_theme(&ColorfulTheme::default())
1836 .with_prompt("Would you like to stash your changes and proceed with checkout?")
1837 .interact()
1838 .map_err(|e| CascadeError::branch(format!("Could not get user confirmation: {e}")))?;
1839
1840 if confirmation {
1841 let stash_message = format!(
1843 "Auto-stash before checkout to {} at {}",
1844 target,
1845 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
1846 );
1847
1848 match self.create_stash(&stash_message) {
1849 Ok(stash_oid) => {
1850 println!("✅ Created stash: {stash_message} ({stash_oid})");
1851 println!("💡 You can restore with: git stash pop");
1852 }
1853 Err(e) => {
1854 println!("❌ Failed to create stash: {e}");
1855
1856 let force_confirm = Confirm::with_theme(&ColorfulTheme::default())
1857 .with_prompt("Stash failed. Force checkout anyway? (WILL LOSE CHANGES)")
1858 .interact()
1859 .map_err(|e| {
1860 CascadeError::branch(format!("Could not get confirmation: {e}"))
1861 })?;
1862
1863 if !force_confirm {
1864 return Err(CascadeError::branch(
1865 "Checkout cancelled by user".to_string(),
1866 ));
1867 }
1868 }
1869 }
1870 } else {
1871 return Err(CascadeError::branch(
1872 "Checkout cancelled by user".to_string(),
1873 ));
1874 }
1875
1876 Ok(())
1877 }
1878
1879 fn create_stash(&self, message: &str) -> Result<String> {
1881 warn!("Automatic stashing not yet implemented - please stash manually");
1885 Err(CascadeError::branch(format!(
1886 "Please manually stash your changes first: git stash push -m \"{message}\""
1887 )))
1888 }
1889
1890 fn get_modified_files(&self) -> Result<Vec<String>> {
1892 let mut opts = git2::StatusOptions::new();
1893 opts.include_untracked(false).include_ignored(false);
1894
1895 let statuses = self
1896 .repo
1897 .statuses(Some(&mut opts))
1898 .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
1899
1900 let mut modified_files = Vec::new();
1901 for status in statuses.iter() {
1902 let flags = status.status();
1903 if flags.contains(git2::Status::WT_MODIFIED) || flags.contains(git2::Status::WT_DELETED)
1904 {
1905 if let Some(path) = status.path() {
1906 modified_files.push(path.to_string());
1907 }
1908 }
1909 }
1910
1911 Ok(modified_files)
1912 }
1913
1914 fn get_staged_files(&self) -> Result<Vec<String>> {
1916 let mut opts = git2::StatusOptions::new();
1917 opts.include_untracked(false).include_ignored(false);
1918
1919 let statuses = self
1920 .repo
1921 .statuses(Some(&mut opts))
1922 .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
1923
1924 let mut staged_files = Vec::new();
1925 for status in statuses.iter() {
1926 let flags = status.status();
1927 if flags.contains(git2::Status::INDEX_MODIFIED)
1928 || flags.contains(git2::Status::INDEX_NEW)
1929 || flags.contains(git2::Status::INDEX_DELETED)
1930 {
1931 if let Some(path) = status.path() {
1932 staged_files.push(path.to_string());
1933 }
1934 }
1935 }
1936
1937 Ok(staged_files)
1938 }
1939
1940 fn count_commits_between(&self, from: &str, to: &str) -> Result<usize> {
1942 let commits = self.get_commits_between(from, to)?;
1943 Ok(commits.len())
1944 }
1945
1946 pub fn resolve_reference(&self, reference: &str) -> Result<git2::Commit<'_>> {
1948 if let Ok(oid) = Oid::from_str(reference) {
1950 if let Ok(commit) = self.repo.find_commit(oid) {
1951 return Ok(commit);
1952 }
1953 }
1954
1955 let obj = self.repo.revparse_single(reference).map_err(|e| {
1957 CascadeError::branch(format!("Could not resolve reference '{reference}': {e}"))
1958 })?;
1959
1960 obj.peel_to_commit().map_err(|e| {
1961 CascadeError::branch(format!(
1962 "Reference '{reference}' does not point to a commit: {e}"
1963 ))
1964 })
1965 }
1966
1967 pub fn reset_soft(&self, target_ref: &str) -> Result<()> {
1969 let target_commit = self.resolve_reference(target_ref)?;
1970
1971 self.repo
1972 .reset(target_commit.as_object(), git2::ResetType::Soft, None)
1973 .map_err(CascadeError::Git)?;
1974
1975 Ok(())
1976 }
1977
1978 pub fn find_branch_containing_commit(&self, commit_hash: &str) -> Result<String> {
1980 let oid = Oid::from_str(commit_hash).map_err(|e| {
1981 CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
1982 })?;
1983
1984 let branches = self
1986 .repo
1987 .branches(Some(git2::BranchType::Local))
1988 .map_err(CascadeError::Git)?;
1989
1990 for branch_result in branches {
1991 let (branch, _) = branch_result.map_err(CascadeError::Git)?;
1992
1993 if let Some(branch_name) = branch.name().map_err(CascadeError::Git)? {
1994 if let Ok(branch_head) = branch.get().peel_to_commit() {
1996 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
1998 revwalk.push(branch_head.id()).map_err(CascadeError::Git)?;
1999
2000 for commit_oid in revwalk {
2001 let commit_oid = commit_oid.map_err(CascadeError::Git)?;
2002 if commit_oid == oid {
2003 return Ok(branch_name.to_string());
2004 }
2005 }
2006 }
2007 }
2008 }
2009
2010 Err(CascadeError::branch(format!(
2012 "Commit {commit_hash} not found in any local branch"
2013 )))
2014 }
2015
2016 pub async fn fetch_async(&self) -> Result<()> {
2020 let repo_path = self.path.clone();
2021 crate::utils::async_ops::run_git_operation(move || {
2022 let repo = GitRepository::open(&repo_path)?;
2023 repo.fetch()
2024 })
2025 .await
2026 }
2027
2028 pub async fn pull_async(&self, branch: &str) -> Result<()> {
2030 let repo_path = self.path.clone();
2031 let branch_name = branch.to_string();
2032 crate::utils::async_ops::run_git_operation(move || {
2033 let repo = GitRepository::open(&repo_path)?;
2034 repo.pull(&branch_name)
2035 })
2036 .await
2037 }
2038
2039 pub async fn push_branch_async(&self, branch_name: &str) -> Result<()> {
2041 let repo_path = self.path.clone();
2042 let branch = branch_name.to_string();
2043 crate::utils::async_ops::run_git_operation(move || {
2044 let repo = GitRepository::open(&repo_path)?;
2045 repo.push(&branch)
2046 })
2047 .await
2048 }
2049
2050 pub async fn cherry_pick_commit_async(&self, commit_hash: &str) -> Result<String> {
2052 let repo_path = self.path.clone();
2053 let hash = commit_hash.to_string();
2054 crate::utils::async_ops::run_git_operation(move || {
2055 let repo = GitRepository::open(&repo_path)?;
2056 repo.cherry_pick(&hash)
2057 })
2058 .await
2059 }
2060
2061 pub async fn get_commit_hashes_between_async(
2063 &self,
2064 from: &str,
2065 to: &str,
2066 ) -> Result<Vec<String>> {
2067 let repo_path = self.path.clone();
2068 let from_str = from.to_string();
2069 let to_str = to.to_string();
2070 crate::utils::async_ops::run_git_operation(move || {
2071 let repo = GitRepository::open(&repo_path)?;
2072 let commits = repo.get_commits_between(&from_str, &to_str)?;
2073 Ok(commits.into_iter().map(|c| c.id().to_string()).collect())
2074 })
2075 .await
2076 }
2077
2078 pub fn reset_branch_to_commit(&self, branch_name: &str, commit_hash: &str) -> Result<()> {
2080 info!(
2081 "Resetting branch '{}' to commit {}",
2082 branch_name,
2083 &commit_hash[..8]
2084 );
2085
2086 let target_oid = git2::Oid::from_str(commit_hash).map_err(|e| {
2088 CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
2089 })?;
2090
2091 let _target_commit = self.repo.find_commit(target_oid).map_err(|e| {
2092 CascadeError::branch(format!("Could not find commit '{commit_hash}': {e}"))
2093 })?;
2094
2095 let _branch = self
2097 .repo
2098 .find_branch(branch_name, git2::BranchType::Local)
2099 .map_err(|e| {
2100 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
2101 })?;
2102
2103 let branch_ref_name = format!("refs/heads/{branch_name}");
2105 self.repo
2106 .reference(
2107 &branch_ref_name,
2108 target_oid,
2109 true,
2110 &format!("Reset {branch_name} to {commit_hash}"),
2111 )
2112 .map_err(|e| {
2113 CascadeError::branch(format!(
2114 "Could not reset branch '{branch_name}' to commit '{commit_hash}': {e}"
2115 ))
2116 })?;
2117
2118 tracing::info!(
2119 "Successfully reset branch '{}' to commit {}",
2120 branch_name,
2121 &commit_hash[..8]
2122 );
2123 Ok(())
2124 }
2125}
2126
2127#[cfg(test)]
2128mod tests {
2129 use super::*;
2130 use std::process::Command;
2131 use tempfile::TempDir;
2132
2133 fn create_test_repo() -> (TempDir, PathBuf) {
2134 let temp_dir = TempDir::new().unwrap();
2135 let repo_path = temp_dir.path().to_path_buf();
2136
2137 Command::new("git")
2139 .args(["init"])
2140 .current_dir(&repo_path)
2141 .output()
2142 .unwrap();
2143 Command::new("git")
2144 .args(["config", "user.name", "Test"])
2145 .current_dir(&repo_path)
2146 .output()
2147 .unwrap();
2148 Command::new("git")
2149 .args(["config", "user.email", "test@test.com"])
2150 .current_dir(&repo_path)
2151 .output()
2152 .unwrap();
2153
2154 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
2156 Command::new("git")
2157 .args(["add", "."])
2158 .current_dir(&repo_path)
2159 .output()
2160 .unwrap();
2161 Command::new("git")
2162 .args(["commit", "-m", "Initial commit"])
2163 .current_dir(&repo_path)
2164 .output()
2165 .unwrap();
2166
2167 (temp_dir, repo_path)
2168 }
2169
2170 fn create_commit(repo_path: &PathBuf, message: &str, filename: &str) {
2171 let file_path = repo_path.join(filename);
2172 std::fs::write(&file_path, format!("Content for {filename}\n")).unwrap();
2173
2174 Command::new("git")
2175 .args(["add", filename])
2176 .current_dir(repo_path)
2177 .output()
2178 .unwrap();
2179 Command::new("git")
2180 .args(["commit", "-m", message])
2181 .current_dir(repo_path)
2182 .output()
2183 .unwrap();
2184 }
2185
2186 #[test]
2187 fn test_repository_info() {
2188 let (_temp_dir, repo_path) = create_test_repo();
2189 let repo = GitRepository::open(&repo_path).unwrap();
2190
2191 let info = repo.get_info().unwrap();
2192 assert!(!info.is_dirty); assert!(
2194 info.head_branch == Some("master".to_string())
2195 || info.head_branch == Some("main".to_string()),
2196 "Expected default branch to be 'master' or 'main', got {:?}",
2197 info.head_branch
2198 );
2199 assert!(info.head_commit.is_some()); assert!(info.untracked_files.is_empty()); }
2202
2203 #[test]
2204 fn test_force_push_branch_basic() {
2205 let (_temp_dir, repo_path) = create_test_repo();
2206 let repo = GitRepository::open(&repo_path).unwrap();
2207
2208 let default_branch = repo.get_current_branch().unwrap();
2210
2211 create_commit(&repo_path, "Feature commit 1", "feature1.rs");
2213 Command::new("git")
2214 .args(["checkout", "-b", "source-branch"])
2215 .current_dir(&repo_path)
2216 .output()
2217 .unwrap();
2218 create_commit(&repo_path, "Feature commit 2", "feature2.rs");
2219
2220 Command::new("git")
2222 .args(["checkout", &default_branch])
2223 .current_dir(&repo_path)
2224 .output()
2225 .unwrap();
2226 Command::new("git")
2227 .args(["checkout", "-b", "target-branch"])
2228 .current_dir(&repo_path)
2229 .output()
2230 .unwrap();
2231 create_commit(&repo_path, "Target commit", "target.rs");
2232
2233 let result = repo.force_push_branch("target-branch", "source-branch");
2235
2236 assert!(result.is_ok() || result.is_err()); }
2240
2241 #[test]
2242 fn test_force_push_branch_nonexistent_branches() {
2243 let (_temp_dir, repo_path) = create_test_repo();
2244 let repo = GitRepository::open(&repo_path).unwrap();
2245
2246 let default_branch = repo.get_current_branch().unwrap();
2248
2249 let result = repo.force_push_branch("target", "nonexistent-source");
2251 assert!(result.is_err());
2252
2253 let result = repo.force_push_branch("nonexistent-target", &default_branch);
2255 assert!(result.is_err());
2256 }
2257
2258 #[test]
2259 fn test_force_push_workflow_simulation() {
2260 let (_temp_dir, repo_path) = create_test_repo();
2261 let repo = GitRepository::open(&repo_path).unwrap();
2262
2263 Command::new("git")
2266 .args(["checkout", "-b", "feature-auth"])
2267 .current_dir(&repo_path)
2268 .output()
2269 .unwrap();
2270 create_commit(&repo_path, "Add authentication", "auth.rs");
2271
2272 Command::new("git")
2274 .args(["checkout", "-b", "feature-auth-v2"])
2275 .current_dir(&repo_path)
2276 .output()
2277 .unwrap();
2278 create_commit(&repo_path, "Fix auth validation", "auth.rs");
2279
2280 let result = repo.force_push_branch("feature-auth", "feature-auth-v2");
2282
2283 match result {
2285 Ok(_) => {
2286 Command::new("git")
2288 .args(["checkout", "feature-auth"])
2289 .current_dir(&repo_path)
2290 .output()
2291 .unwrap();
2292 let log_output = Command::new("git")
2293 .args(["log", "--oneline", "-2"])
2294 .current_dir(&repo_path)
2295 .output()
2296 .unwrap();
2297 let log_str = String::from_utf8_lossy(&log_output.stdout);
2298 assert!(
2299 log_str.contains("Fix auth validation")
2300 || log_str.contains("Add authentication")
2301 );
2302 }
2303 Err(_) => {
2304 }
2307 }
2308 }
2309
2310 #[test]
2311 fn test_branch_operations() {
2312 let (_temp_dir, repo_path) = create_test_repo();
2313 let repo = GitRepository::open(&repo_path).unwrap();
2314
2315 let current = repo.get_current_branch().unwrap();
2317 assert!(
2318 current == "master" || current == "main",
2319 "Expected default branch to be 'master' or 'main', got '{current}'"
2320 );
2321
2322 Command::new("git")
2324 .args(["checkout", "-b", "test-branch"])
2325 .current_dir(&repo_path)
2326 .output()
2327 .unwrap();
2328 let current = repo.get_current_branch().unwrap();
2329 assert_eq!(current, "test-branch");
2330 }
2331
2332 #[test]
2333 fn test_commit_operations() {
2334 let (_temp_dir, repo_path) = create_test_repo();
2335 let repo = GitRepository::open(&repo_path).unwrap();
2336
2337 let head = repo.get_head_commit().unwrap();
2339 assert_eq!(head.message().unwrap().trim(), "Initial commit");
2340
2341 let hash = head.id().to_string();
2343 let same_commit = repo.get_commit(&hash).unwrap();
2344 assert_eq!(head.id(), same_commit.id());
2345 }
2346
2347 #[test]
2348 fn test_checkout_safety_clean_repo() {
2349 let (_temp_dir, repo_path) = create_test_repo();
2350 let repo = GitRepository::open(&repo_path).unwrap();
2351
2352 create_commit(&repo_path, "Second commit", "test.txt");
2354 Command::new("git")
2355 .args(["checkout", "-b", "test-branch"])
2356 .current_dir(&repo_path)
2357 .output()
2358 .unwrap();
2359
2360 let safety_result = repo.check_checkout_safety("main");
2362 assert!(safety_result.is_ok());
2363 assert!(safety_result.unwrap().is_none()); }
2365
2366 #[test]
2367 fn test_checkout_safety_with_modified_files() {
2368 let (_temp_dir, repo_path) = create_test_repo();
2369 let repo = GitRepository::open(&repo_path).unwrap();
2370
2371 Command::new("git")
2373 .args(["checkout", "-b", "test-branch"])
2374 .current_dir(&repo_path)
2375 .output()
2376 .unwrap();
2377
2378 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2380
2381 let safety_result = repo.check_checkout_safety("main");
2383 assert!(safety_result.is_ok());
2384 let safety_info = safety_result.unwrap();
2385 assert!(safety_info.is_some());
2386
2387 let info = safety_info.unwrap();
2388 assert!(!info.modified_files.is_empty());
2389 assert!(info.modified_files.contains(&"README.md".to_string()));
2390 }
2391
2392 #[test]
2393 fn test_unsafe_checkout_methods() {
2394 let (_temp_dir, repo_path) = create_test_repo();
2395 let repo = GitRepository::open(&repo_path).unwrap();
2396
2397 create_commit(&repo_path, "Second commit", "test.txt");
2399 Command::new("git")
2400 .args(["checkout", "-b", "test-branch"])
2401 .current_dir(&repo_path)
2402 .output()
2403 .unwrap();
2404
2405 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2407
2408 let _result = repo.checkout_branch_unsafe("master");
2410 let head_commit = repo.get_head_commit().unwrap();
2415 let commit_hash = head_commit.id().to_string();
2416 let _result = repo.checkout_commit_unsafe(&commit_hash);
2417 }
2419
2420 #[test]
2421 fn test_get_modified_files() {
2422 let (_temp_dir, repo_path) = create_test_repo();
2423 let repo = GitRepository::open(&repo_path).unwrap();
2424
2425 let modified = repo.get_modified_files().unwrap();
2427 assert!(modified.is_empty());
2428
2429 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2431
2432 let modified = repo.get_modified_files().unwrap();
2434 assert_eq!(modified.len(), 1);
2435 assert!(modified.contains(&"README.md".to_string()));
2436 }
2437
2438 #[test]
2439 fn test_get_staged_files() {
2440 let (_temp_dir, repo_path) = create_test_repo();
2441 let repo = GitRepository::open(&repo_path).unwrap();
2442
2443 let staged = repo.get_staged_files().unwrap();
2445 assert!(staged.is_empty());
2446
2447 std::fs::write(repo_path.join("staged.txt"), "Staged content").unwrap();
2449 Command::new("git")
2450 .args(["add", "staged.txt"])
2451 .current_dir(&repo_path)
2452 .output()
2453 .unwrap();
2454
2455 let staged = repo.get_staged_files().unwrap();
2457 assert_eq!(staged.len(), 1);
2458 assert!(staged.contains(&"staged.txt".to_string()));
2459 }
2460
2461 #[test]
2462 fn test_create_stash_fallback() {
2463 let (_temp_dir, repo_path) = create_test_repo();
2464 let repo = GitRepository::open(&repo_path).unwrap();
2465
2466 let result = repo.create_stash("test stash");
2468 assert!(result.is_err());
2469 let error_msg = result.unwrap_err().to_string();
2470 assert!(error_msg.contains("git stash push"));
2471 }
2472
2473 #[test]
2474 fn test_delete_branch_unsafe() {
2475 let (_temp_dir, repo_path) = create_test_repo();
2476 let repo = GitRepository::open(&repo_path).unwrap();
2477
2478 create_commit(&repo_path, "Second commit", "test.txt");
2480 Command::new("git")
2481 .args(["checkout", "-b", "test-branch"])
2482 .current_dir(&repo_path)
2483 .output()
2484 .unwrap();
2485
2486 create_commit(&repo_path, "Branch-specific commit", "branch.txt");
2488
2489 Command::new("git")
2491 .args(["checkout", "master"])
2492 .current_dir(&repo_path)
2493 .output()
2494 .unwrap();
2495
2496 let result = repo.delete_branch_unsafe("test-branch");
2499 let _ = result; }
2503
2504 #[test]
2505 fn test_force_push_unsafe() {
2506 let (_temp_dir, repo_path) = create_test_repo();
2507 let repo = GitRepository::open(&repo_path).unwrap();
2508
2509 create_commit(&repo_path, "Second commit", "test.txt");
2511 Command::new("git")
2512 .args(["checkout", "-b", "test-branch"])
2513 .current_dir(&repo_path)
2514 .output()
2515 .unwrap();
2516
2517 let _result = repo.force_push_branch_unsafe("test-branch", "test-branch");
2520 }
2522}