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 path(&self) -> &Path {
479 &self.path
480 }
481
482 pub fn commit_exists(&self, commit_hash: &str) -> Result<bool> {
484 match Oid::from_str(commit_hash) {
485 Ok(oid) => match self.repo.find_commit(oid) {
486 Ok(_) => Ok(true),
487 Err(_) => Ok(false),
488 },
489 Err(_) => Ok(false),
490 }
491 }
492
493 pub fn get_head_commit(&self) -> Result<git2::Commit<'_>> {
495 let head = self
496 .repo
497 .head()
498 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
499 head.peel_to_commit()
500 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))
501 }
502
503 pub fn get_commit(&self, commit_hash: &str) -> Result<git2::Commit<'_>> {
505 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
506
507 self.repo.find_commit(oid).map_err(CascadeError::Git)
508 }
509
510 pub fn get_branch_head(&self, branch_name: &str) -> Result<String> {
512 let branch = self
513 .repo
514 .find_branch(branch_name, git2::BranchType::Local)
515 .map_err(|e| {
516 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
517 })?;
518
519 let commit = branch.get().peel_to_commit().map_err(|e| {
520 CascadeError::branch(format!(
521 "Could not get commit for branch '{branch_name}': {e}"
522 ))
523 })?;
524
525 Ok(commit.id().to_string())
526 }
527
528 fn get_signature(&self) -> Result<Signature<'_>> {
530 if let Ok(config) = self.repo.config() {
532 if let (Ok(name), Ok(email)) = (
533 config.get_string("user.name"),
534 config.get_string("user.email"),
535 ) {
536 return Signature::now(&name, &email).map_err(CascadeError::Git);
537 }
538 }
539
540 Signature::now("Cascade CLI", "cascade@example.com").map_err(CascadeError::Git)
542 }
543
544 fn configure_remote_callbacks(&self) -> Result<git2::RemoteCallbacks<'_>> {
547 let mut callbacks = git2::RemoteCallbacks::new();
548
549 callbacks.credentials(|_url, username_from_url, _allowed_types| {
551 if let Some(username) = username_from_url {
552 git2::Cred::ssh_key_from_agent(username)
554 } else {
555 git2::Cred::default()
557 }
558 });
559
560 let mut ssl_configured = false;
565
566 if let Some(ssl_config) = &self.ssl_config {
568 if ssl_config.accept_invalid_certs {
569 tracing::warn!(
570 "SSL certificate verification DISABLED via Cascade config - this is insecure!"
571 );
572 callbacks.certificate_check(|_cert, _host| {
573 tracing::debug!("⚠️ Accepting invalid certificate for host: {}", _host);
574 Ok(git2::CertificateCheckStatus::CertificateOk)
575 });
576 ssl_configured = true;
577 } else if let Some(ca_path) = &ssl_config.ca_bundle_path {
578 tracing::info!("Using custom CA bundle from Cascade config: {}", ca_path);
579 callbacks.certificate_check(|_cert, host| {
580 tracing::debug!("Using custom CA bundle for host: {}", host);
581 Ok(git2::CertificateCheckStatus::CertificateOk)
582 });
583 ssl_configured = true;
584 }
585 }
586
587 if !ssl_configured {
589 if let Ok(config) = self.repo.config() {
590 let ssl_verify = config.get_bool("http.sslVerify").unwrap_or(true);
591
592 if !ssl_verify {
593 tracing::warn!(
594 "SSL certificate verification DISABLED via git config - this is insecure!"
595 );
596 callbacks.certificate_check(|_cert, host| {
597 tracing::debug!("⚠️ Bypassing SSL verification for host: {}", host);
598 Ok(git2::CertificateCheckStatus::CertificateOk)
599 });
600 ssl_configured = true;
601 } else if let Ok(ca_path) = config.get_string("http.sslCAInfo") {
602 tracing::info!("Using custom CA bundle from git config: {}", ca_path);
603 callbacks.certificate_check(|_cert, host| {
604 tracing::debug!("Using git config CA bundle for host: {}", host);
605 Ok(git2::CertificateCheckStatus::CertificateOk)
606 });
607 ssl_configured = true;
608 }
609 }
610 }
611
612 if !ssl_configured {
615 tracing::debug!(
616 "Using system certificate store for SSL verification (default behavior)"
617 );
618
619 if cfg!(target_os = "macos") {
621 tracing::debug!("macOS detected - using default certificate validation");
622 } else {
625 callbacks.certificate_check(|_cert, host| {
627 tracing::debug!("System certificate validation for host: {}", host);
628 Ok(git2::CertificateCheckStatus::CertificatePassthrough)
629 });
630 }
631 }
632
633 Ok(callbacks)
634 }
635
636 fn get_index_tree(&self) -> Result<Oid> {
638 let mut index = self.repo.index().map_err(CascadeError::Git)?;
639
640 index.write_tree().map_err(CascadeError::Git)
641 }
642
643 pub fn get_status(&self) -> Result<git2::Statuses<'_>> {
645 self.repo.statuses(None).map_err(CascadeError::Git)
646 }
647
648 pub fn get_remote_url(&self, name: &str) -> Result<String> {
650 let remote = self.repo.find_remote(name).map_err(CascadeError::Git)?;
651
652 let url = remote.url().ok_or_else(|| {
653 CascadeError::Git(git2::Error::from_str("Remote URL is not valid UTF-8"))
654 })?;
655
656 Ok(url.to_string())
657 }
658
659 pub fn cherry_pick(&self, commit_hash: &str) -> Result<String> {
661 tracing::debug!("Cherry-picking commit {}", commit_hash);
662
663 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
664 let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
665
666 let commit_tree = commit.tree().map_err(CascadeError::Git)?;
668
669 let parent_commit = if commit.parent_count() > 0 {
671 commit.parent(0).map_err(CascadeError::Git)?
672 } else {
673 let empty_tree_oid = self.repo.treebuilder(None)?.write()?;
675 let empty_tree = self.repo.find_tree(empty_tree_oid)?;
676 let sig = self.get_signature()?;
677 return self
678 .repo
679 .commit(
680 Some("HEAD"),
681 &sig,
682 &sig,
683 commit.message().unwrap_or("Cherry-picked commit"),
684 &empty_tree,
685 &[],
686 )
687 .map(|oid| oid.to_string())
688 .map_err(CascadeError::Git);
689 };
690
691 let parent_tree = parent_commit.tree().map_err(CascadeError::Git)?;
692
693 let head_commit = self.get_head_commit()?;
695 let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
696
697 let mut index = self
699 .repo
700 .merge_trees(&parent_tree, &head_tree, &commit_tree, None)
701 .map_err(CascadeError::Git)?;
702
703 if index.has_conflicts() {
705 return Err(CascadeError::branch(format!(
706 "Cherry-pick of {commit_hash} has conflicts that need manual resolution"
707 )));
708 }
709
710 let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
712 let merged_tree = self
713 .repo
714 .find_tree(merged_tree_oid)
715 .map_err(CascadeError::Git)?;
716
717 let signature = self.get_signature()?;
719 let message = format!("Cherry-pick: {}", commit.message().unwrap_or(""));
720
721 let new_commit_oid = self
722 .repo
723 .commit(
724 Some("HEAD"),
725 &signature,
726 &signature,
727 &message,
728 &merged_tree,
729 &[&head_commit],
730 )
731 .map_err(CascadeError::Git)?;
732
733 tracing::info!("Cherry-picked {} -> {}", commit_hash, new_commit_oid);
734 Ok(new_commit_oid.to_string())
735 }
736
737 pub fn has_conflicts(&self) -> Result<bool> {
739 let index = self.repo.index().map_err(CascadeError::Git)?;
740 Ok(index.has_conflicts())
741 }
742
743 pub fn get_conflicted_files(&self) -> Result<Vec<String>> {
745 let index = self.repo.index().map_err(CascadeError::Git)?;
746
747 let mut conflicts = Vec::new();
748
749 let conflict_iter = index.conflicts().map_err(CascadeError::Git)?;
751
752 for conflict in conflict_iter {
753 let conflict = conflict.map_err(CascadeError::Git)?;
754 if let Some(our) = conflict.our {
755 if let Ok(path) = std::str::from_utf8(&our.path) {
756 conflicts.push(path.to_string());
757 }
758 } else if let Some(their) = conflict.their {
759 if let Ok(path) = std::str::from_utf8(&their.path) {
760 conflicts.push(path.to_string());
761 }
762 }
763 }
764
765 Ok(conflicts)
766 }
767
768 pub fn fetch(&self) -> Result<()> {
770 tracing::info!("Fetching from origin");
771
772 let mut remote = self
773 .repo
774 .find_remote("origin")
775 .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
776
777 let callbacks = self.configure_remote_callbacks()?;
779
780 let mut fetch_options = git2::FetchOptions::new();
782 fetch_options.remote_callbacks(callbacks);
783
784 match remote.fetch::<&str>(&[], Some(&mut fetch_options), None) {
786 Ok(_) => {
787 tracing::debug!("Fetch completed successfully");
788 Ok(())
789 }
790 Err(e) => {
791 let error_string = e.to_string();
793 if error_string.contains("TLS stream") || error_string.contains("SSL") {
794 tracing::warn!(
795 "git2 TLS error detected, falling back to git CLI for fetch operation"
796 );
797 return self.fetch_with_git_cli();
798 }
799 Err(CascadeError::Git(e))
800 }
801 }
802 }
803
804 pub fn pull(&self, branch: &str) -> Result<()> {
806 tracing::info!("Pulling branch: {}", branch);
807
808 match self.fetch() {
810 Ok(_) => {}
811 Err(e) => {
812 let error_string = e.to_string();
814 if error_string.contains("TLS stream") || error_string.contains("SSL") {
815 tracing::warn!(
816 "git2 TLS error detected, falling back to git CLI for pull operation"
817 );
818 return self.pull_with_git_cli(branch);
819 }
820 return Err(e);
821 }
822 }
823
824 let remote_branch_name = format!("origin/{branch}");
826 let remote_oid = self
827 .repo
828 .refname_to_id(&format!("refs/remotes/{remote_branch_name}"))
829 .map_err(|e| {
830 CascadeError::branch(format!("Remote branch {remote_branch_name} not found: {e}"))
831 })?;
832
833 let remote_commit = self
834 .repo
835 .find_commit(remote_oid)
836 .map_err(CascadeError::Git)?;
837
838 let head_commit = self.get_head_commit()?;
840
841 if head_commit.id() == remote_commit.id() {
843 tracing::debug!("Already up to date");
844 return Ok(());
845 }
846
847 let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
849 let remote_tree = remote_commit.tree().map_err(CascadeError::Git)?;
850
851 let merge_base_oid = self
853 .repo
854 .merge_base(head_commit.id(), remote_commit.id())
855 .map_err(CascadeError::Git)?;
856 let merge_base_commit = self
857 .repo
858 .find_commit(merge_base_oid)
859 .map_err(CascadeError::Git)?;
860 let merge_base_tree = merge_base_commit.tree().map_err(CascadeError::Git)?;
861
862 let mut index = self
864 .repo
865 .merge_trees(&merge_base_tree, &head_tree, &remote_tree, None)
866 .map_err(CascadeError::Git)?;
867
868 if index.has_conflicts() {
869 return Err(CascadeError::branch(
870 "Pull has conflicts that need manual resolution".to_string(),
871 ));
872 }
873
874 let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
876 let merged_tree = self
877 .repo
878 .find_tree(merged_tree_oid)
879 .map_err(CascadeError::Git)?;
880
881 let signature = self.get_signature()?;
882 let message = format!("Merge branch '{branch}' from origin");
883
884 self.repo
885 .commit(
886 Some("HEAD"),
887 &signature,
888 &signature,
889 &message,
890 &merged_tree,
891 &[&head_commit, &remote_commit],
892 )
893 .map_err(CascadeError::Git)?;
894
895 tracing::info!("Pull completed successfully");
896 Ok(())
897 }
898
899 pub fn push(&self, branch: &str) -> Result<()> {
901 tracing::info!("Pushing branch: {}", branch);
902
903 let mut remote = self
904 .repo
905 .find_remote("origin")
906 .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
907
908 let remote_url = remote.url().unwrap_or("unknown").to_string();
909 tracing::debug!("Remote URL: {}", remote_url);
910
911 let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
912 tracing::debug!("Push refspec: {}", refspec);
913
914 let mut callbacks = self.configure_remote_callbacks()?;
916
917 callbacks.push_update_reference(|refname, status| {
919 if let Some(msg) = status {
920 tracing::error!("Push failed for ref {}: {}", refname, msg);
921 return Err(git2::Error::from_str(&format!("Push failed: {msg}")));
922 }
923 tracing::debug!("Push succeeded for ref: {}", refname);
924 Ok(())
925 });
926
927 let mut push_options = git2::PushOptions::new();
929 push_options.remote_callbacks(callbacks);
930
931 match remote.push(&[&refspec], Some(&mut push_options)) {
933 Ok(_) => {
934 tracing::info!("Push completed successfully for branch: {}", branch);
935 Ok(())
936 }
937 Err(e) => {
938 let error_string = e.to_string();
940 if error_string.contains("TLS stream") || error_string.contains("SSL") {
941 tracing::warn!(
942 "git2 TLS error detected, falling back to git CLI for push operation"
943 );
944 return self.push_with_git_cli(branch);
945 }
946
947 let error_msg = format!(
949 "Failed to push branch '{}' to remote '{}': {}. \
950 \nDebugging hints:\
951 \n - Check network connectivity: ping {}\
952 \n - Verify authentication: git remote -v\
953 \n - Test manual push: git push origin {}\
954 \n - Check SSL settings if using HTTPS\
955 \n - For corporate networks, consider SSL certificate configuration",
956 branch,
957 remote_url,
958 e,
959 remote_url.split("://").nth(1).unwrap_or("unknown"),
960 branch
961 );
962
963 tracing::error!("{}", error_msg);
964 Err(CascadeError::branch(error_msg))
965 }
966 }
967 }
968
969 fn push_with_git_cli(&self, branch: &str) -> Result<()> {
972 tracing::info!("Using git CLI fallback for push operation: {}", branch);
973
974 let output = std::process::Command::new("git")
975 .args(["push", "origin", branch])
976 .current_dir(&self.path)
977 .output()
978 .map_err(|e| CascadeError::branch(format!("Failed to execute git command: {e}")))?;
979
980 if output.status.success() {
981 tracing::info!("✅ Git CLI push succeeded for branch: {}", branch);
982 Ok(())
983 } else {
984 let stderr = String::from_utf8_lossy(&output.stderr);
985 let stdout = String::from_utf8_lossy(&output.stdout);
986 let error_msg = format!(
987 "Git CLI push failed for branch '{}': {}\nStdout: {}\nStderr: {}",
988 branch, output.status, stdout, stderr
989 );
990 tracing::error!("{}", error_msg);
991 Err(CascadeError::branch(error_msg))
992 }
993 }
994
995 fn fetch_with_git_cli(&self) -> Result<()> {
998 tracing::info!("Using git CLI fallback for fetch operation");
999
1000 let output = std::process::Command::new("git")
1001 .args(["fetch", "origin"])
1002 .current_dir(&self.path)
1003 .output()
1004 .map_err(|e| {
1005 CascadeError::Git(git2::Error::from_str(&format!(
1006 "Failed to execute git command: {e}"
1007 )))
1008 })?;
1009
1010 if output.status.success() {
1011 tracing::info!("✅ Git CLI fetch succeeded");
1012 Ok(())
1013 } else {
1014 let stderr = String::from_utf8_lossy(&output.stderr);
1015 let stdout = String::from_utf8_lossy(&output.stdout);
1016 let error_msg = format!(
1017 "Git CLI fetch failed: {}\nStdout: {}\nStderr: {}",
1018 output.status, stdout, stderr
1019 );
1020 tracing::error!("{}", error_msg);
1021 Err(CascadeError::Git(git2::Error::from_str(&error_msg)))
1022 }
1023 }
1024
1025 fn pull_with_git_cli(&self, branch: &str) -> Result<()> {
1028 tracing::info!("Using git CLI fallback for pull operation: {}", branch);
1029
1030 let output = std::process::Command::new("git")
1031 .args(["pull", "origin", branch])
1032 .current_dir(&self.path)
1033 .output()
1034 .map_err(|e| {
1035 CascadeError::Git(git2::Error::from_str(&format!(
1036 "Failed to execute git command: {e}"
1037 )))
1038 })?;
1039
1040 if output.status.success() {
1041 tracing::info!("✅ Git CLI pull succeeded for branch: {}", branch);
1042 Ok(())
1043 } else {
1044 let stderr = String::from_utf8_lossy(&output.stderr);
1045 let stdout = String::from_utf8_lossy(&output.stdout);
1046 let error_msg = format!(
1047 "Git CLI pull failed for branch '{}': {}\nStdout: {}\nStderr: {}",
1048 branch, output.status, stdout, stderr
1049 );
1050 tracing::error!("{}", error_msg);
1051 Err(CascadeError::Git(git2::Error::from_str(&error_msg)))
1052 }
1053 }
1054
1055 fn force_push_with_git_cli(&self, branch: &str) -> Result<()> {
1058 tracing::info!(
1059 "Using git CLI fallback for force push operation: {}",
1060 branch
1061 );
1062
1063 let output = std::process::Command::new("git")
1064 .args(["push", "--force", "origin", branch])
1065 .current_dir(&self.path)
1066 .output()
1067 .map_err(|e| CascadeError::branch(format!("Failed to execute git command: {e}")))?;
1068
1069 if output.status.success() {
1070 tracing::info!("✅ Git CLI force push succeeded for branch: {}", branch);
1071 Ok(())
1072 } else {
1073 let stderr = String::from_utf8_lossy(&output.stderr);
1074 let stdout = String::from_utf8_lossy(&output.stdout);
1075 let error_msg = format!(
1076 "Git CLI force push failed for branch '{}': {}\nStdout: {}\nStderr: {}",
1077 branch, output.status, stdout, stderr
1078 );
1079 tracing::error!("{}", error_msg);
1080 Err(CascadeError::branch(error_msg))
1081 }
1082 }
1083
1084 pub fn delete_branch(&self, name: &str) -> Result<()> {
1086 self.delete_branch_with_options(name, false)
1087 }
1088
1089 pub fn delete_branch_unsafe(&self, name: &str) -> Result<()> {
1091 self.delete_branch_with_options(name, true)
1092 }
1093
1094 fn delete_branch_with_options(&self, name: &str, force_unsafe: bool) -> Result<()> {
1096 info!("Attempting to delete branch: {}", name);
1097
1098 if !force_unsafe {
1100 let safety_result = self.check_branch_deletion_safety(name)?;
1101 if let Some(safety_info) = safety_result {
1102 self.handle_branch_deletion_confirmation(name, &safety_info)?;
1104 }
1105 }
1106
1107 let mut branch = self
1108 .repo
1109 .find_branch(name, git2::BranchType::Local)
1110 .map_err(|e| CascadeError::branch(format!("Could not find branch '{name}': {e}")))?;
1111
1112 branch
1113 .delete()
1114 .map_err(|e| CascadeError::branch(format!("Could not delete branch '{name}': {e}")))?;
1115
1116 info!("Successfully deleted branch '{}'", name);
1117 Ok(())
1118 }
1119
1120 pub fn get_commits_between(&self, from: &str, to: &str) -> Result<Vec<git2::Commit<'_>>> {
1122 let from_oid = self
1123 .repo
1124 .refname_to_id(&format!("refs/heads/{from}"))
1125 .or_else(|_| Oid::from_str(from))
1126 .map_err(|e| CascadeError::branch(format!("Invalid from reference '{from}': {e}")))?;
1127
1128 let to_oid = self
1129 .repo
1130 .refname_to_id(&format!("refs/heads/{to}"))
1131 .or_else(|_| Oid::from_str(to))
1132 .map_err(|e| CascadeError::branch(format!("Invalid to reference '{to}': {e}")))?;
1133
1134 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
1135
1136 revwalk.push(to_oid).map_err(CascadeError::Git)?;
1137 revwalk.hide(from_oid).map_err(CascadeError::Git)?;
1138
1139 let mut commits = Vec::new();
1140 for oid in revwalk {
1141 let oid = oid.map_err(CascadeError::Git)?;
1142 let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
1143 commits.push(commit);
1144 }
1145
1146 Ok(commits)
1147 }
1148
1149 pub fn force_push_branch(&self, target_branch: &str, source_branch: &str) -> Result<()> {
1152 self.force_push_branch_with_options(target_branch, source_branch, false)
1153 }
1154
1155 pub fn force_push_branch_unsafe(&self, target_branch: &str, source_branch: &str) -> Result<()> {
1157 self.force_push_branch_with_options(target_branch, source_branch, true)
1158 }
1159
1160 fn force_push_branch_with_options(
1162 &self,
1163 target_branch: &str,
1164 source_branch: &str,
1165 force_unsafe: bool,
1166 ) -> Result<()> {
1167 info!(
1168 "Force pushing {} content to {} to preserve PR history",
1169 source_branch, target_branch
1170 );
1171
1172 if !force_unsafe {
1174 let safety_result = self.check_force_push_safety_enhanced(target_branch)?;
1175 if let Some(backup_info) = safety_result {
1176 self.create_backup_branch(target_branch, &backup_info.remote_commit_id)?;
1178 info!(
1179 "✅ Created backup branch: {}",
1180 backup_info.backup_branch_name
1181 );
1182 }
1183 }
1184
1185 let source_ref = self
1187 .repo
1188 .find_reference(&format!("refs/heads/{source_branch}"))
1189 .map_err(|e| {
1190 CascadeError::config(format!("Failed to find source branch {source_branch}: {e}"))
1191 })?;
1192 let source_commit = source_ref.peel_to_commit().map_err(|e| {
1193 CascadeError::config(format!(
1194 "Failed to get commit for source branch {source_branch}: {e}"
1195 ))
1196 })?;
1197
1198 let mut target_ref = self
1200 .repo
1201 .find_reference(&format!("refs/heads/{target_branch}"))
1202 .map_err(|e| {
1203 CascadeError::config(format!("Failed to find target branch {target_branch}: {e}"))
1204 })?;
1205
1206 target_ref
1207 .set_target(source_commit.id(), "Force push from rebase")
1208 .map_err(|e| {
1209 CascadeError::config(format!(
1210 "Failed to update target branch {target_branch}: {e}"
1211 ))
1212 })?;
1213
1214 let mut remote = self
1216 .repo
1217 .find_remote("origin")
1218 .map_err(|e| CascadeError::config(format!("Failed to find origin remote: {e}")))?;
1219
1220 let refspec = format!("+refs/heads/{target_branch}:refs/heads/{target_branch}");
1221
1222 let callbacks = self.configure_remote_callbacks()?;
1224
1225 let mut push_options = git2::PushOptions::new();
1227 push_options.remote_callbacks(callbacks);
1228
1229 match remote.push(&[&refspec], Some(&mut push_options)) {
1230 Ok(_) => {}
1231 Err(e) => {
1232 let error_string = e.to_string();
1234 if error_string.contains("TLS stream") || error_string.contains("SSL") {
1235 tracing::warn!(
1236 "git2 TLS error detected, falling back to git CLI for force push operation"
1237 );
1238 return self.force_push_with_git_cli(target_branch);
1239 }
1240 return Err(CascadeError::config(format!(
1241 "Failed to force push {target_branch}: {e}"
1242 )));
1243 }
1244 }
1245
1246 info!(
1247 "✅ Successfully force pushed {} to preserve PR history",
1248 target_branch
1249 );
1250 Ok(())
1251 }
1252
1253 fn check_force_push_safety_enhanced(
1256 &self,
1257 target_branch: &str,
1258 ) -> Result<Option<ForceBackupInfo>> {
1259 match self.fetch() {
1261 Ok(_) => {}
1262 Err(e) => {
1263 warn!("Could not fetch latest changes for safety check: {}", e);
1265 }
1266 }
1267
1268 let remote_ref = format!("refs/remotes/origin/{target_branch}");
1270 let local_ref = format!("refs/heads/{target_branch}");
1271
1272 let local_commit = match self.repo.find_reference(&local_ref) {
1274 Ok(reference) => reference.peel_to_commit().ok(),
1275 Err(_) => None,
1276 };
1277
1278 let remote_commit = match self.repo.find_reference(&remote_ref) {
1279 Ok(reference) => reference.peel_to_commit().ok(),
1280 Err(_) => None,
1281 };
1282
1283 if let (Some(local), Some(remote)) = (local_commit, remote_commit) {
1285 if local.id() != remote.id() {
1286 let merge_base_oid = self
1288 .repo
1289 .merge_base(local.id(), remote.id())
1290 .map_err(|e| CascadeError::config(format!("Failed to find merge base: {e}")))?;
1291
1292 if merge_base_oid != remote.id() {
1294 let commits_to_lose = self.count_commits_between(
1295 &merge_base_oid.to_string(),
1296 &remote.id().to_string(),
1297 )?;
1298
1299 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1301 let backup_branch_name = format!("{target_branch}_backup_{timestamp}");
1302
1303 warn!(
1304 "⚠️ Force push to '{}' would overwrite {} commits on remote",
1305 target_branch, commits_to_lose
1306 );
1307
1308 if std::env::var("CI").is_ok() || std::env::var("FORCE_PUSH_NO_CONFIRM").is_ok()
1310 {
1311 info!(
1312 "Non-interactive environment detected, proceeding with backup creation"
1313 );
1314 return Ok(Some(ForceBackupInfo {
1315 backup_branch_name,
1316 remote_commit_id: remote.id().to_string(),
1317 commits_that_would_be_lost: commits_to_lose,
1318 }));
1319 }
1320
1321 println!("\n⚠️ FORCE PUSH WARNING ⚠️");
1323 println!("Force push to '{target_branch}' would overwrite {commits_to_lose} commits on remote:");
1324
1325 match self
1327 .get_commits_between(&merge_base_oid.to_string(), &remote.id().to_string())
1328 {
1329 Ok(commits) => {
1330 println!("\nCommits that would be lost:");
1331 for (i, commit) in commits.iter().take(5).enumerate() {
1332 let short_hash = &commit.id().to_string()[..8];
1333 let summary = commit.summary().unwrap_or("<no message>");
1334 println!(" {}. {} - {}", i + 1, short_hash, summary);
1335 }
1336 if commits.len() > 5 {
1337 println!(" ... and {} more commits", commits.len() - 5);
1338 }
1339 }
1340 Err(_) => {
1341 println!(" (Unable to retrieve commit details)");
1342 }
1343 }
1344
1345 println!("\nA backup branch '{backup_branch_name}' will be created before proceeding.");
1346
1347 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
1348 .with_prompt("Do you want to proceed with the force push?")
1349 .default(false)
1350 .interact()
1351 .map_err(|e| {
1352 CascadeError::config(format!("Failed to get user confirmation: {e}"))
1353 })?;
1354
1355 if !confirmed {
1356 return Err(CascadeError::config(
1357 "Force push cancelled by user. Use --force to bypass this check."
1358 .to_string(),
1359 ));
1360 }
1361
1362 return Ok(Some(ForceBackupInfo {
1363 backup_branch_name,
1364 remote_commit_id: remote.id().to_string(),
1365 commits_that_would_be_lost: commits_to_lose,
1366 }));
1367 }
1368 }
1369 }
1370
1371 Ok(None)
1372 }
1373
1374 fn create_backup_branch(&self, original_branch: &str, remote_commit_id: &str) -> Result<()> {
1376 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1377 let backup_branch_name = format!("{original_branch}_backup_{timestamp}");
1378
1379 let commit_oid = Oid::from_str(remote_commit_id).map_err(|e| {
1381 CascadeError::config(format!("Invalid commit ID {remote_commit_id}: {e}"))
1382 })?;
1383
1384 let commit = self.repo.find_commit(commit_oid).map_err(|e| {
1386 CascadeError::config(format!("Failed to find commit {remote_commit_id}: {e}"))
1387 })?;
1388
1389 self.repo
1391 .branch(&backup_branch_name, &commit, false)
1392 .map_err(|e| {
1393 CascadeError::config(format!(
1394 "Failed to create backup branch {backup_branch_name}: {e}"
1395 ))
1396 })?;
1397
1398 info!(
1399 "✅ Created backup branch '{}' pointing to {}",
1400 backup_branch_name,
1401 &remote_commit_id[..8]
1402 );
1403 Ok(())
1404 }
1405
1406 fn check_branch_deletion_safety(
1409 &self,
1410 branch_name: &str,
1411 ) -> Result<Option<BranchDeletionSafety>> {
1412 match self.fetch() {
1414 Ok(_) => {}
1415 Err(e) => {
1416 warn!(
1417 "Could not fetch latest changes for branch deletion safety check: {}",
1418 e
1419 );
1420 }
1421 }
1422
1423 let branch = self
1425 .repo
1426 .find_branch(branch_name, git2::BranchType::Local)
1427 .map_err(|e| {
1428 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
1429 })?;
1430
1431 let _branch_commit = branch.get().peel_to_commit().map_err(|e| {
1432 CascadeError::branch(format!(
1433 "Could not get commit for branch '{branch_name}': {e}"
1434 ))
1435 })?;
1436
1437 let main_branch_name = self.detect_main_branch()?;
1439
1440 let is_merged_to_main = self.is_branch_merged_to_main(branch_name, &main_branch_name)?;
1442
1443 let remote_tracking_branch = self.get_remote_tracking_branch(branch_name);
1445
1446 let mut unpushed_commits = Vec::new();
1447
1448 if let Some(ref remote_branch) = remote_tracking_branch {
1450 match self.get_commits_between(remote_branch, branch_name) {
1451 Ok(commits) => {
1452 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1453 }
1454 Err(_) => {
1455 if !is_merged_to_main {
1457 if let Ok(commits) =
1458 self.get_commits_between(&main_branch_name, branch_name)
1459 {
1460 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1461 }
1462 }
1463 }
1464 }
1465 } else if !is_merged_to_main {
1466 if let Ok(commits) = self.get_commits_between(&main_branch_name, branch_name) {
1468 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1469 }
1470 }
1471
1472 if !unpushed_commits.is_empty() || (!is_merged_to_main && remote_tracking_branch.is_none())
1474 {
1475 Ok(Some(BranchDeletionSafety {
1476 unpushed_commits,
1477 remote_tracking_branch,
1478 is_merged_to_main,
1479 main_branch_name,
1480 }))
1481 } else {
1482 Ok(None)
1483 }
1484 }
1485
1486 fn handle_branch_deletion_confirmation(
1488 &self,
1489 branch_name: &str,
1490 safety_info: &BranchDeletionSafety,
1491 ) -> Result<()> {
1492 if std::env::var("CI").is_ok() || std::env::var("BRANCH_DELETE_NO_CONFIRM").is_ok() {
1494 return Err(CascadeError::branch(
1495 format!(
1496 "Branch '{branch_name}' has {} unpushed commits and cannot be deleted in non-interactive mode. Use --force to override.",
1497 safety_info.unpushed_commits.len()
1498 )
1499 ));
1500 }
1501
1502 println!("\n⚠️ BRANCH DELETION WARNING ⚠️");
1504 println!("Branch '{branch_name}' has potential issues:");
1505
1506 if !safety_info.unpushed_commits.is_empty() {
1507 println!(
1508 "\n🔍 Unpushed commits ({} total):",
1509 safety_info.unpushed_commits.len()
1510 );
1511
1512 for (i, commit_id) in safety_info.unpushed_commits.iter().take(5).enumerate() {
1514 if let Ok(commit) = self.repo.find_commit(Oid::from_str(commit_id).unwrap()) {
1515 let short_hash = &commit_id[..8];
1516 let summary = commit.summary().unwrap_or("<no message>");
1517 println!(" {}. {} - {}", i + 1, short_hash, summary);
1518 }
1519 }
1520
1521 if safety_info.unpushed_commits.len() > 5 {
1522 println!(
1523 " ... and {} more commits",
1524 safety_info.unpushed_commits.len() - 5
1525 );
1526 }
1527 }
1528
1529 if !safety_info.is_merged_to_main {
1530 println!("\n📋 Branch status:");
1531 println!(" • Not merged to '{}'", safety_info.main_branch_name);
1532 if let Some(ref remote) = safety_info.remote_tracking_branch {
1533 println!(" • Remote tracking branch: {remote}");
1534 } else {
1535 println!(" • No remote tracking branch");
1536 }
1537 }
1538
1539 println!("\n💡 Safer alternatives:");
1540 if !safety_info.unpushed_commits.is_empty() {
1541 if let Some(ref _remote) = safety_info.remote_tracking_branch {
1542 println!(" • Push commits first: git push origin {branch_name}");
1543 } else {
1544 println!(" • Create and push to remote: git push -u origin {branch_name}");
1545 }
1546 }
1547 if !safety_info.is_merged_to_main {
1548 println!(
1549 " • Merge to {} first: git checkout {} && git merge {branch_name}",
1550 safety_info.main_branch_name, safety_info.main_branch_name
1551 );
1552 }
1553
1554 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
1555 .with_prompt("Do you want to proceed with deleting this branch?")
1556 .default(false)
1557 .interact()
1558 .map_err(|e| CascadeError::branch(format!("Failed to get user confirmation: {e}")))?;
1559
1560 if !confirmed {
1561 return Err(CascadeError::branch(
1562 "Branch deletion cancelled by user. Use --force to bypass this check.".to_string(),
1563 ));
1564 }
1565
1566 Ok(())
1567 }
1568
1569 fn detect_main_branch(&self) -> Result<String> {
1571 let main_candidates = ["main", "master", "develop", "trunk"];
1572
1573 for candidate in &main_candidates {
1574 if self
1575 .repo
1576 .find_branch(candidate, git2::BranchType::Local)
1577 .is_ok()
1578 {
1579 return Ok(candidate.to_string());
1580 }
1581 }
1582
1583 if let Ok(head) = self.repo.head() {
1585 if let Some(name) = head.shorthand() {
1586 return Ok(name.to_string());
1587 }
1588 }
1589
1590 Ok("main".to_string())
1592 }
1593
1594 fn is_branch_merged_to_main(&self, branch_name: &str, main_branch: &str) -> Result<bool> {
1596 match self.get_commits_between(main_branch, branch_name) {
1598 Ok(commits) => Ok(commits.is_empty()),
1599 Err(_) => {
1600 Ok(false)
1602 }
1603 }
1604 }
1605
1606 fn get_remote_tracking_branch(&self, branch_name: &str) -> Option<String> {
1608 let remote_candidates = [
1610 format!("origin/{branch_name}"),
1611 format!("remotes/origin/{branch_name}"),
1612 ];
1613
1614 for candidate in &remote_candidates {
1615 if self
1616 .repo
1617 .find_reference(&format!(
1618 "refs/remotes/{}",
1619 candidate.replace("remotes/", "")
1620 ))
1621 .is_ok()
1622 {
1623 return Some(candidate.clone());
1624 }
1625 }
1626
1627 None
1628 }
1629
1630 fn check_checkout_safety(&self, _target: &str) -> Result<Option<CheckoutSafety>> {
1632 let is_dirty = self.is_dirty()?;
1634 if !is_dirty {
1635 return Ok(None);
1637 }
1638
1639 let current_branch = self.get_current_branch().ok();
1641
1642 let modified_files = self.get_modified_files()?;
1644 let staged_files = self.get_staged_files()?;
1645 let untracked_files = self.get_untracked_files()?;
1646
1647 let has_uncommitted_changes = !modified_files.is_empty() || !staged_files.is_empty();
1648
1649 if has_uncommitted_changes || !untracked_files.is_empty() {
1650 return Ok(Some(CheckoutSafety {
1651 has_uncommitted_changes,
1652 modified_files,
1653 staged_files,
1654 untracked_files,
1655 stash_created: None,
1656 current_branch,
1657 }));
1658 }
1659
1660 Ok(None)
1661 }
1662
1663 fn handle_checkout_confirmation(
1665 &self,
1666 target: &str,
1667 safety_info: &CheckoutSafety,
1668 ) -> Result<()> {
1669 let is_ci = std::env::var("CI").is_ok();
1671 let no_confirm = std::env::var("CHECKOUT_NO_CONFIRM").is_ok();
1672 let is_non_interactive = is_ci || no_confirm;
1673
1674 if is_non_interactive {
1675 return Err(CascadeError::branch(
1676 format!(
1677 "Cannot checkout '{target}' with uncommitted changes in non-interactive mode. Commit your changes or use stash first."
1678 )
1679 ));
1680 }
1681
1682 println!("\n⚠️ CHECKOUT WARNING ⚠️");
1684 println!("You have uncommitted changes that could be lost:");
1685
1686 if !safety_info.modified_files.is_empty() {
1687 println!(
1688 "\n📝 Modified files ({}):",
1689 safety_info.modified_files.len()
1690 );
1691 for file in safety_info.modified_files.iter().take(10) {
1692 println!(" - {file}");
1693 }
1694 if safety_info.modified_files.len() > 10 {
1695 println!(" ... and {} more", safety_info.modified_files.len() - 10);
1696 }
1697 }
1698
1699 if !safety_info.staged_files.is_empty() {
1700 println!("\n📁 Staged files ({}):", safety_info.staged_files.len());
1701 for file in safety_info.staged_files.iter().take(10) {
1702 println!(" - {file}");
1703 }
1704 if safety_info.staged_files.len() > 10 {
1705 println!(" ... and {} more", safety_info.staged_files.len() - 10);
1706 }
1707 }
1708
1709 if !safety_info.untracked_files.is_empty() {
1710 println!(
1711 "\n❓ Untracked files ({}):",
1712 safety_info.untracked_files.len()
1713 );
1714 for file in safety_info.untracked_files.iter().take(5) {
1715 println!(" - {file}");
1716 }
1717 if safety_info.untracked_files.len() > 5 {
1718 println!(" ... and {} more", safety_info.untracked_files.len() - 5);
1719 }
1720 }
1721
1722 println!("\n🔄 Options:");
1723 println!("1. Stash changes and checkout (recommended)");
1724 println!("2. Force checkout (WILL LOSE UNCOMMITTED CHANGES)");
1725 println!("3. Cancel checkout");
1726
1727 let confirmation = Confirm::with_theme(&ColorfulTheme::default())
1728 .with_prompt("Would you like to stash your changes and proceed with checkout?")
1729 .interact()
1730 .map_err(|e| CascadeError::branch(format!("Could not get user confirmation: {e}")))?;
1731
1732 if confirmation {
1733 let stash_message = format!(
1735 "Auto-stash before checkout to {} at {}",
1736 target,
1737 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
1738 );
1739
1740 match self.create_stash(&stash_message) {
1741 Ok(stash_oid) => {
1742 println!("✅ Created stash: {stash_message} ({stash_oid})");
1743 println!("💡 You can restore with: git stash pop");
1744 }
1745 Err(e) => {
1746 println!("❌ Failed to create stash: {e}");
1747
1748 let force_confirm = Confirm::with_theme(&ColorfulTheme::default())
1749 .with_prompt("Stash failed. Force checkout anyway? (WILL LOSE CHANGES)")
1750 .interact()
1751 .map_err(|e| {
1752 CascadeError::branch(format!("Could not get confirmation: {e}"))
1753 })?;
1754
1755 if !force_confirm {
1756 return Err(CascadeError::branch(
1757 "Checkout cancelled by user".to_string(),
1758 ));
1759 }
1760 }
1761 }
1762 } else {
1763 return Err(CascadeError::branch(
1764 "Checkout cancelled by user".to_string(),
1765 ));
1766 }
1767
1768 Ok(())
1769 }
1770
1771 fn create_stash(&self, message: &str) -> Result<String> {
1773 warn!("Automatic stashing not yet implemented - please stash manually");
1777 Err(CascadeError::branch(format!(
1778 "Please manually stash your changes first: git stash push -m \"{message}\""
1779 )))
1780 }
1781
1782 fn get_modified_files(&self) -> Result<Vec<String>> {
1784 let mut opts = git2::StatusOptions::new();
1785 opts.include_untracked(false).include_ignored(false);
1786
1787 let statuses = self
1788 .repo
1789 .statuses(Some(&mut opts))
1790 .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
1791
1792 let mut modified_files = Vec::new();
1793 for status in statuses.iter() {
1794 let flags = status.status();
1795 if flags.contains(git2::Status::WT_MODIFIED) || flags.contains(git2::Status::WT_DELETED)
1796 {
1797 if let Some(path) = status.path() {
1798 modified_files.push(path.to_string());
1799 }
1800 }
1801 }
1802
1803 Ok(modified_files)
1804 }
1805
1806 fn get_staged_files(&self) -> Result<Vec<String>> {
1808 let mut opts = git2::StatusOptions::new();
1809 opts.include_untracked(false).include_ignored(false);
1810
1811 let statuses = self
1812 .repo
1813 .statuses(Some(&mut opts))
1814 .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
1815
1816 let mut staged_files = Vec::new();
1817 for status in statuses.iter() {
1818 let flags = status.status();
1819 if flags.contains(git2::Status::INDEX_MODIFIED)
1820 || flags.contains(git2::Status::INDEX_NEW)
1821 || flags.contains(git2::Status::INDEX_DELETED)
1822 {
1823 if let Some(path) = status.path() {
1824 staged_files.push(path.to_string());
1825 }
1826 }
1827 }
1828
1829 Ok(staged_files)
1830 }
1831
1832 fn count_commits_between(&self, from: &str, to: &str) -> Result<usize> {
1834 let commits = self.get_commits_between(from, to)?;
1835 Ok(commits.len())
1836 }
1837
1838 pub fn resolve_reference(&self, reference: &str) -> Result<git2::Commit<'_>> {
1840 if let Ok(oid) = Oid::from_str(reference) {
1842 if let Ok(commit) = self.repo.find_commit(oid) {
1843 return Ok(commit);
1844 }
1845 }
1846
1847 let obj = self.repo.revparse_single(reference).map_err(|e| {
1849 CascadeError::branch(format!("Could not resolve reference '{reference}': {e}"))
1850 })?;
1851
1852 obj.peel_to_commit().map_err(|e| {
1853 CascadeError::branch(format!(
1854 "Reference '{reference}' does not point to a commit: {e}"
1855 ))
1856 })
1857 }
1858
1859 pub fn reset_soft(&self, target_ref: &str) -> Result<()> {
1861 let target_commit = self.resolve_reference(target_ref)?;
1862
1863 self.repo
1864 .reset(target_commit.as_object(), git2::ResetType::Soft, None)
1865 .map_err(CascadeError::Git)?;
1866
1867 Ok(())
1868 }
1869
1870 pub fn find_branch_containing_commit(&self, commit_hash: &str) -> Result<String> {
1872 let oid = Oid::from_str(commit_hash).map_err(|e| {
1873 CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
1874 })?;
1875
1876 let branches = self
1878 .repo
1879 .branches(Some(git2::BranchType::Local))
1880 .map_err(CascadeError::Git)?;
1881
1882 for branch_result in branches {
1883 let (branch, _) = branch_result.map_err(CascadeError::Git)?;
1884
1885 if let Some(branch_name) = branch.name().map_err(CascadeError::Git)? {
1886 if let Ok(branch_head) = branch.get().peel_to_commit() {
1888 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
1890 revwalk.push(branch_head.id()).map_err(CascadeError::Git)?;
1891
1892 for commit_oid in revwalk {
1893 let commit_oid = commit_oid.map_err(CascadeError::Git)?;
1894 if commit_oid == oid {
1895 return Ok(branch_name.to_string());
1896 }
1897 }
1898 }
1899 }
1900 }
1901
1902 Err(CascadeError::branch(format!(
1904 "Commit {commit_hash} not found in any local branch"
1905 )))
1906 }
1907
1908 pub async fn fetch_async(&self) -> Result<()> {
1912 let repo_path = self.path.clone();
1913 crate::utils::async_ops::run_git_operation(move || {
1914 let repo = GitRepository::open(&repo_path)?;
1915 repo.fetch()
1916 })
1917 .await
1918 }
1919
1920 pub async fn pull_async(&self, branch: &str) -> Result<()> {
1922 let repo_path = self.path.clone();
1923 let branch_name = branch.to_string();
1924 crate::utils::async_ops::run_git_operation(move || {
1925 let repo = GitRepository::open(&repo_path)?;
1926 repo.pull(&branch_name)
1927 })
1928 .await
1929 }
1930
1931 pub async fn push_branch_async(&self, branch_name: &str) -> Result<()> {
1933 let repo_path = self.path.clone();
1934 let branch = branch_name.to_string();
1935 crate::utils::async_ops::run_git_operation(move || {
1936 let repo = GitRepository::open(&repo_path)?;
1937 repo.push(&branch)
1938 })
1939 .await
1940 }
1941
1942 pub async fn cherry_pick_commit_async(&self, commit_hash: &str) -> Result<String> {
1944 let repo_path = self.path.clone();
1945 let hash = commit_hash.to_string();
1946 crate::utils::async_ops::run_git_operation(move || {
1947 let repo = GitRepository::open(&repo_path)?;
1948 repo.cherry_pick(&hash)
1949 })
1950 .await
1951 }
1952
1953 pub async fn get_commit_hashes_between_async(
1955 &self,
1956 from: &str,
1957 to: &str,
1958 ) -> Result<Vec<String>> {
1959 let repo_path = self.path.clone();
1960 let from_str = from.to_string();
1961 let to_str = to.to_string();
1962 crate::utils::async_ops::run_git_operation(move || {
1963 let repo = GitRepository::open(&repo_path)?;
1964 let commits = repo.get_commits_between(&from_str, &to_str)?;
1965 Ok(commits.into_iter().map(|c| c.id().to_string()).collect())
1966 })
1967 .await
1968 }
1969
1970 pub fn reset_branch_to_commit(&self, branch_name: &str, commit_hash: &str) -> Result<()> {
1972 info!(
1973 "Resetting branch '{}' to commit {}",
1974 branch_name,
1975 &commit_hash[..8]
1976 );
1977
1978 let target_oid = git2::Oid::from_str(commit_hash).map_err(|e| {
1980 CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
1981 })?;
1982
1983 let _target_commit = self.repo.find_commit(target_oid).map_err(|e| {
1984 CascadeError::branch(format!("Could not find commit '{commit_hash}': {e}"))
1985 })?;
1986
1987 let _branch = self
1989 .repo
1990 .find_branch(branch_name, git2::BranchType::Local)
1991 .map_err(|e| {
1992 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
1993 })?;
1994
1995 let branch_ref_name = format!("refs/heads/{branch_name}");
1997 self.repo
1998 .reference(
1999 &branch_ref_name,
2000 target_oid,
2001 true,
2002 &format!("Reset {branch_name} to {commit_hash}"),
2003 )
2004 .map_err(|e| {
2005 CascadeError::branch(format!(
2006 "Could not reset branch '{branch_name}' to commit '{commit_hash}': {e}"
2007 ))
2008 })?;
2009
2010 tracing::info!(
2011 "Successfully reset branch '{}' to commit {}",
2012 branch_name,
2013 &commit_hash[..8]
2014 );
2015 Ok(())
2016 }
2017}
2018
2019#[cfg(test)]
2020mod tests {
2021 use super::*;
2022 use std::process::Command;
2023 use tempfile::TempDir;
2024
2025 fn create_test_repo() -> (TempDir, PathBuf) {
2026 let temp_dir = TempDir::new().unwrap();
2027 let repo_path = temp_dir.path().to_path_buf();
2028
2029 Command::new("git")
2031 .args(["init"])
2032 .current_dir(&repo_path)
2033 .output()
2034 .unwrap();
2035 Command::new("git")
2036 .args(["config", "user.name", "Test"])
2037 .current_dir(&repo_path)
2038 .output()
2039 .unwrap();
2040 Command::new("git")
2041 .args(["config", "user.email", "test@test.com"])
2042 .current_dir(&repo_path)
2043 .output()
2044 .unwrap();
2045
2046 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
2048 Command::new("git")
2049 .args(["add", "."])
2050 .current_dir(&repo_path)
2051 .output()
2052 .unwrap();
2053 Command::new("git")
2054 .args(["commit", "-m", "Initial commit"])
2055 .current_dir(&repo_path)
2056 .output()
2057 .unwrap();
2058
2059 (temp_dir, repo_path)
2060 }
2061
2062 fn create_commit(repo_path: &PathBuf, message: &str, filename: &str) {
2063 let file_path = repo_path.join(filename);
2064 std::fs::write(&file_path, format!("Content for {filename}\n")).unwrap();
2065
2066 Command::new("git")
2067 .args(["add", filename])
2068 .current_dir(repo_path)
2069 .output()
2070 .unwrap();
2071 Command::new("git")
2072 .args(["commit", "-m", message])
2073 .current_dir(repo_path)
2074 .output()
2075 .unwrap();
2076 }
2077
2078 #[test]
2079 fn test_repository_info() {
2080 let (_temp_dir, repo_path) = create_test_repo();
2081 let repo = GitRepository::open(&repo_path).unwrap();
2082
2083 let info = repo.get_info().unwrap();
2084 assert!(!info.is_dirty); assert!(
2086 info.head_branch == Some("master".to_string())
2087 || info.head_branch == Some("main".to_string()),
2088 "Expected default branch to be 'master' or 'main', got {:?}",
2089 info.head_branch
2090 );
2091 assert!(info.head_commit.is_some()); assert!(info.untracked_files.is_empty()); }
2094
2095 #[test]
2096 fn test_force_push_branch_basic() {
2097 let (_temp_dir, repo_path) = create_test_repo();
2098 let repo = GitRepository::open(&repo_path).unwrap();
2099
2100 let default_branch = repo.get_current_branch().unwrap();
2102
2103 create_commit(&repo_path, "Feature commit 1", "feature1.rs");
2105 Command::new("git")
2106 .args(["checkout", "-b", "source-branch"])
2107 .current_dir(&repo_path)
2108 .output()
2109 .unwrap();
2110 create_commit(&repo_path, "Feature commit 2", "feature2.rs");
2111
2112 Command::new("git")
2114 .args(["checkout", &default_branch])
2115 .current_dir(&repo_path)
2116 .output()
2117 .unwrap();
2118 Command::new("git")
2119 .args(["checkout", "-b", "target-branch"])
2120 .current_dir(&repo_path)
2121 .output()
2122 .unwrap();
2123 create_commit(&repo_path, "Target commit", "target.rs");
2124
2125 let result = repo.force_push_branch("target-branch", "source-branch");
2127
2128 assert!(result.is_ok() || result.is_err()); }
2132
2133 #[test]
2134 fn test_force_push_branch_nonexistent_branches() {
2135 let (_temp_dir, repo_path) = create_test_repo();
2136 let repo = GitRepository::open(&repo_path).unwrap();
2137
2138 let default_branch = repo.get_current_branch().unwrap();
2140
2141 let result = repo.force_push_branch("target", "nonexistent-source");
2143 assert!(result.is_err());
2144
2145 let result = repo.force_push_branch("nonexistent-target", &default_branch);
2147 assert!(result.is_err());
2148 }
2149
2150 #[test]
2151 fn test_force_push_workflow_simulation() {
2152 let (_temp_dir, repo_path) = create_test_repo();
2153 let repo = GitRepository::open(&repo_path).unwrap();
2154
2155 Command::new("git")
2158 .args(["checkout", "-b", "feature-auth"])
2159 .current_dir(&repo_path)
2160 .output()
2161 .unwrap();
2162 create_commit(&repo_path, "Add authentication", "auth.rs");
2163
2164 Command::new("git")
2166 .args(["checkout", "-b", "feature-auth-v2"])
2167 .current_dir(&repo_path)
2168 .output()
2169 .unwrap();
2170 create_commit(&repo_path, "Fix auth validation", "auth.rs");
2171
2172 let result = repo.force_push_branch("feature-auth", "feature-auth-v2");
2174
2175 match result {
2177 Ok(_) => {
2178 Command::new("git")
2180 .args(["checkout", "feature-auth"])
2181 .current_dir(&repo_path)
2182 .output()
2183 .unwrap();
2184 let log_output = Command::new("git")
2185 .args(["log", "--oneline", "-2"])
2186 .current_dir(&repo_path)
2187 .output()
2188 .unwrap();
2189 let log_str = String::from_utf8_lossy(&log_output.stdout);
2190 assert!(
2191 log_str.contains("Fix auth validation")
2192 || log_str.contains("Add authentication")
2193 );
2194 }
2195 Err(_) => {
2196 }
2199 }
2200 }
2201
2202 #[test]
2203 fn test_branch_operations() {
2204 let (_temp_dir, repo_path) = create_test_repo();
2205 let repo = GitRepository::open(&repo_path).unwrap();
2206
2207 let current = repo.get_current_branch().unwrap();
2209 assert!(
2210 current == "master" || current == "main",
2211 "Expected default branch to be 'master' or 'main', got '{current}'"
2212 );
2213
2214 Command::new("git")
2216 .args(["checkout", "-b", "test-branch"])
2217 .current_dir(&repo_path)
2218 .output()
2219 .unwrap();
2220 let current = repo.get_current_branch().unwrap();
2221 assert_eq!(current, "test-branch");
2222 }
2223
2224 #[test]
2225 fn test_commit_operations() {
2226 let (_temp_dir, repo_path) = create_test_repo();
2227 let repo = GitRepository::open(&repo_path).unwrap();
2228
2229 let head = repo.get_head_commit().unwrap();
2231 assert_eq!(head.message().unwrap().trim(), "Initial commit");
2232
2233 let hash = head.id().to_string();
2235 let same_commit = repo.get_commit(&hash).unwrap();
2236 assert_eq!(head.id(), same_commit.id());
2237 }
2238
2239 #[test]
2240 fn test_checkout_safety_clean_repo() {
2241 let (_temp_dir, repo_path) = create_test_repo();
2242 let repo = GitRepository::open(&repo_path).unwrap();
2243
2244 create_commit(&repo_path, "Second commit", "test.txt");
2246 Command::new("git")
2247 .args(["checkout", "-b", "test-branch"])
2248 .current_dir(&repo_path)
2249 .output()
2250 .unwrap();
2251
2252 let safety_result = repo.check_checkout_safety("main");
2254 assert!(safety_result.is_ok());
2255 assert!(safety_result.unwrap().is_none()); }
2257
2258 #[test]
2259 fn test_checkout_safety_with_modified_files() {
2260 let (_temp_dir, repo_path) = create_test_repo();
2261 let repo = GitRepository::open(&repo_path).unwrap();
2262
2263 Command::new("git")
2265 .args(["checkout", "-b", "test-branch"])
2266 .current_dir(&repo_path)
2267 .output()
2268 .unwrap();
2269
2270 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2272
2273 let safety_result = repo.check_checkout_safety("main");
2275 assert!(safety_result.is_ok());
2276 let safety_info = safety_result.unwrap();
2277 assert!(safety_info.is_some());
2278
2279 let info = safety_info.unwrap();
2280 assert!(!info.modified_files.is_empty());
2281 assert!(info.modified_files.contains(&"README.md".to_string()));
2282 }
2283
2284 #[test]
2285 fn test_unsafe_checkout_methods() {
2286 let (_temp_dir, repo_path) = create_test_repo();
2287 let repo = GitRepository::open(&repo_path).unwrap();
2288
2289 create_commit(&repo_path, "Second commit", "test.txt");
2291 Command::new("git")
2292 .args(["checkout", "-b", "test-branch"])
2293 .current_dir(&repo_path)
2294 .output()
2295 .unwrap();
2296
2297 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2299
2300 let _result = repo.checkout_branch_unsafe("master");
2302 let head_commit = repo.get_head_commit().unwrap();
2307 let commit_hash = head_commit.id().to_string();
2308 let _result = repo.checkout_commit_unsafe(&commit_hash);
2309 }
2311
2312 #[test]
2313 fn test_get_modified_files() {
2314 let (_temp_dir, repo_path) = create_test_repo();
2315 let repo = GitRepository::open(&repo_path).unwrap();
2316
2317 let modified = repo.get_modified_files().unwrap();
2319 assert!(modified.is_empty());
2320
2321 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2323
2324 let modified = repo.get_modified_files().unwrap();
2326 assert_eq!(modified.len(), 1);
2327 assert!(modified.contains(&"README.md".to_string()));
2328 }
2329
2330 #[test]
2331 fn test_get_staged_files() {
2332 let (_temp_dir, repo_path) = create_test_repo();
2333 let repo = GitRepository::open(&repo_path).unwrap();
2334
2335 let staged = repo.get_staged_files().unwrap();
2337 assert!(staged.is_empty());
2338
2339 std::fs::write(repo_path.join("staged.txt"), "Staged content").unwrap();
2341 Command::new("git")
2342 .args(["add", "staged.txt"])
2343 .current_dir(&repo_path)
2344 .output()
2345 .unwrap();
2346
2347 let staged = repo.get_staged_files().unwrap();
2349 assert_eq!(staged.len(), 1);
2350 assert!(staged.contains(&"staged.txt".to_string()));
2351 }
2352
2353 #[test]
2354 fn test_create_stash_fallback() {
2355 let (_temp_dir, repo_path) = create_test_repo();
2356 let repo = GitRepository::open(&repo_path).unwrap();
2357
2358 let result = repo.create_stash("test stash");
2360 assert!(result.is_err());
2361 let error_msg = result.unwrap_err().to_string();
2362 assert!(error_msg.contains("git stash push"));
2363 }
2364
2365 #[test]
2366 fn test_delete_branch_unsafe() {
2367 let (_temp_dir, repo_path) = create_test_repo();
2368 let repo = GitRepository::open(&repo_path).unwrap();
2369
2370 create_commit(&repo_path, "Second commit", "test.txt");
2372 Command::new("git")
2373 .args(["checkout", "-b", "test-branch"])
2374 .current_dir(&repo_path)
2375 .output()
2376 .unwrap();
2377
2378 create_commit(&repo_path, "Branch-specific commit", "branch.txt");
2380
2381 Command::new("git")
2383 .args(["checkout", "master"])
2384 .current_dir(&repo_path)
2385 .output()
2386 .unwrap();
2387
2388 let result = repo.delete_branch_unsafe("test-branch");
2391 let _ = result; }
2395
2396 #[test]
2397 fn test_force_push_unsafe() {
2398 let (_temp_dir, repo_path) = create_test_repo();
2399 let repo = GitRepository::open(&repo_path).unwrap();
2400
2401 create_commit(&repo_path, "Second commit", "test.txt");
2403 Command::new("git")
2404 .args(["checkout", "-b", "test-branch"])
2405 .current_dir(&repo_path)
2406 .output()
2407 .unwrap();
2408
2409 let _result = repo.force_push_branch_unsafe("test-branch", "test-branch");
2412 }
2414}