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