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
50pub struct GitRepository {
56 repo: Repository,
57 path: PathBuf,
58}
59
60impl GitRepository {
61 pub fn open(path: &Path) -> Result<Self> {
63 let repo = Repository::discover(path)
64 .map_err(|e| CascadeError::config(format!("Not a git repository: {e}")))?;
65
66 let workdir = repo
67 .workdir()
68 .ok_or_else(|| CascadeError::config("Repository has no working directory"))?
69 .to_path_buf();
70
71 Ok(Self {
72 repo,
73 path: workdir,
74 })
75 }
76
77 pub fn get_info(&self) -> Result<RepositoryInfo> {
79 let head_branch = self.get_current_branch().ok();
80 let head_commit = self.get_head_commit_hash().ok();
81 let is_dirty = self.is_dirty()?;
82 let untracked_files = self.get_untracked_files()?;
83
84 Ok(RepositoryInfo {
85 path: self.path.clone(),
86 head_branch,
87 head_commit,
88 is_dirty,
89 untracked_files,
90 })
91 }
92
93 pub fn get_current_branch(&self) -> Result<String> {
95 let head = self
96 .repo
97 .head()
98 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
99
100 if let Some(name) = head.shorthand() {
101 Ok(name.to_string())
102 } else {
103 let commit = head
105 .peel_to_commit()
106 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))?;
107 Ok(format!("HEAD@{}", commit.id()))
108 }
109 }
110
111 pub fn get_head_commit_hash(&self) -> Result<String> {
113 let head = self
114 .repo
115 .head()
116 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
117
118 let commit = head
119 .peel_to_commit()
120 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))?;
121
122 Ok(commit.id().to_string())
123 }
124
125 pub fn is_dirty(&self) -> Result<bool> {
127 let statuses = self.repo.statuses(None).map_err(CascadeError::Git)?;
128
129 for status in statuses.iter() {
130 let flags = status.status();
131
132 if flags.intersects(
134 git2::Status::INDEX_MODIFIED
135 | git2::Status::INDEX_NEW
136 | git2::Status::INDEX_DELETED
137 | git2::Status::WT_MODIFIED
138 | git2::Status::WT_NEW
139 | git2::Status::WT_DELETED,
140 ) {
141 return Ok(true);
142 }
143 }
144
145 Ok(false)
146 }
147
148 pub fn get_untracked_files(&self) -> Result<Vec<String>> {
150 let statuses = self.repo.statuses(None).map_err(CascadeError::Git)?;
151
152 let mut untracked = Vec::new();
153 for status in statuses.iter() {
154 if status.status().contains(git2::Status::WT_NEW) {
155 if let Some(path) = status.path() {
156 untracked.push(path.to_string());
157 }
158 }
159 }
160
161 Ok(untracked)
162 }
163
164 pub fn create_branch(&self, name: &str, target: Option<&str>) -> Result<()> {
166 let target_commit = if let Some(target) = target {
167 let target_obj = self.repo.revparse_single(target).map_err(|e| {
169 CascadeError::branch(format!("Could not find target '{target}': {e}"))
170 })?;
171 target_obj.peel_to_commit().map_err(|e| {
172 CascadeError::branch(format!("Target '{target}' is not a commit: {e}"))
173 })?
174 } else {
175 let head = self
177 .repo
178 .head()
179 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
180 head.peel_to_commit()
181 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))?
182 };
183
184 self.repo
185 .branch(name, &target_commit, false)
186 .map_err(|e| CascadeError::branch(format!("Could not create branch '{name}': {e}")))?;
187
188 tracing::info!("Created branch '{}'", name);
189 Ok(())
190 }
191
192 pub fn checkout_branch(&self, name: &str) -> Result<()> {
194 self.checkout_branch_with_options(name, false)
195 }
196
197 pub fn checkout_branch_unsafe(&self, name: &str) -> Result<()> {
199 self.checkout_branch_with_options(name, true)
200 }
201
202 fn checkout_branch_with_options(&self, name: &str, force_unsafe: bool) -> Result<()> {
204 info!("Attempting to checkout branch: {}", name);
205
206 if !force_unsafe {
208 let safety_result = self.check_checkout_safety(name)?;
209 if let Some(safety_info) = safety_result {
210 self.handle_checkout_confirmation(name, &safety_info)?;
212 }
213 }
214
215 let branch = self
217 .repo
218 .find_branch(name, git2::BranchType::Local)
219 .map_err(|e| CascadeError::branch(format!("Could not find branch '{name}': {e}")))?;
220
221 let branch_ref = branch.get();
222 let tree = branch_ref.peel_to_tree().map_err(|e| {
223 CascadeError::branch(format!("Could not get tree for branch '{name}': {e}"))
224 })?;
225
226 self.repo
228 .checkout_tree(tree.as_object(), None)
229 .map_err(|e| {
230 CascadeError::branch(format!("Could not checkout branch '{name}': {e}"))
231 })?;
232
233 self.repo
235 .set_head(&format!("refs/heads/{name}"))
236 .map_err(|e| CascadeError::branch(format!("Could not update HEAD to '{name}': {e}")))?;
237
238 tracing::info!("Switched to branch '{}'", name);
239 Ok(())
240 }
241
242 pub fn checkout_commit(&self, commit_hash: &str) -> Result<()> {
244 self.checkout_commit_with_options(commit_hash, false)
245 }
246
247 pub fn checkout_commit_unsafe(&self, commit_hash: &str) -> Result<()> {
249 self.checkout_commit_with_options(commit_hash, true)
250 }
251
252 fn checkout_commit_with_options(&self, commit_hash: &str, force_unsafe: bool) -> Result<()> {
254 info!("Attempting to checkout commit: {}", commit_hash);
255
256 if !force_unsafe {
258 let safety_result = self.check_checkout_safety(&format!("commit:{commit_hash}"))?;
259 if let Some(safety_info) = safety_result {
260 self.handle_checkout_confirmation(&format!("commit {commit_hash}"), &safety_info)?;
262 }
263 }
264
265 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
266
267 let commit = self.repo.find_commit(oid).map_err(|e| {
268 CascadeError::branch(format!("Could not find commit '{commit_hash}': {e}"))
269 })?;
270
271 let tree = commit.tree().map_err(|e| {
272 CascadeError::branch(format!(
273 "Could not get tree for commit '{commit_hash}': {e}"
274 ))
275 })?;
276
277 self.repo
279 .checkout_tree(tree.as_object(), None)
280 .map_err(|e| {
281 CascadeError::branch(format!("Could not checkout commit '{commit_hash}': {e}"))
282 })?;
283
284 self.repo.set_head_detached(oid).map_err(|e| {
286 CascadeError::branch(format!(
287 "Could not update HEAD to commit '{commit_hash}': {e}"
288 ))
289 })?;
290
291 tracing::info!("Checked out commit '{}' (detached HEAD)", commit_hash);
292 Ok(())
293 }
294
295 pub fn branch_exists(&self, name: &str) -> bool {
297 self.repo.find_branch(name, git2::BranchType::Local).is_ok()
298 }
299
300 pub fn get_branch_commit_hash(&self, branch_name: &str) -> Result<String> {
302 let branch = self
303 .repo
304 .find_branch(branch_name, git2::BranchType::Local)
305 .map_err(|e| {
306 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
307 })?;
308
309 let commit = branch.get().peel_to_commit().map_err(|e| {
310 CascadeError::branch(format!(
311 "Could not get commit for branch '{branch_name}': {e}"
312 ))
313 })?;
314
315 Ok(commit.id().to_string())
316 }
317
318 pub fn list_branches(&self) -> Result<Vec<String>> {
320 let branches = self
321 .repo
322 .branches(Some(git2::BranchType::Local))
323 .map_err(CascadeError::Git)?;
324
325 let mut branch_names = Vec::new();
326 for branch in branches {
327 let (branch, _) = branch.map_err(CascadeError::Git)?;
328 if let Some(name) = branch.name().map_err(CascadeError::Git)? {
329 branch_names.push(name.to_string());
330 }
331 }
332
333 Ok(branch_names)
334 }
335
336 pub fn commit(&self, message: &str) -> Result<String> {
338 let signature = self.get_signature()?;
339 let tree_id = self.get_index_tree()?;
340 let tree = self.repo.find_tree(tree_id).map_err(CascadeError::Git)?;
341
342 let head = self.repo.head().map_err(CascadeError::Git)?;
344 let parent_commit = head.peel_to_commit().map_err(CascadeError::Git)?;
345
346 let commit_id = self
347 .repo
348 .commit(
349 Some("HEAD"),
350 &signature,
351 &signature,
352 message,
353 &tree,
354 &[&parent_commit],
355 )
356 .map_err(CascadeError::Git)?;
357
358 tracing::info!("Created commit: {} - {}", commit_id, message);
359 Ok(commit_id.to_string())
360 }
361
362 pub fn stage_all(&self) -> Result<()> {
364 let mut index = self.repo.index().map_err(CascadeError::Git)?;
365
366 index
367 .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
368 .map_err(CascadeError::Git)?;
369
370 index.write().map_err(CascadeError::Git)?;
371
372 tracing::debug!("Staged all changes");
373 Ok(())
374 }
375
376 pub fn path(&self) -> &Path {
378 &self.path
379 }
380
381 pub fn commit_exists(&self, commit_hash: &str) -> Result<bool> {
383 match Oid::from_str(commit_hash) {
384 Ok(oid) => match self.repo.find_commit(oid) {
385 Ok(_) => Ok(true),
386 Err(_) => Ok(false),
387 },
388 Err(_) => Ok(false),
389 }
390 }
391
392 pub fn get_head_commit(&self) -> Result<git2::Commit<'_>> {
394 let head = self
395 .repo
396 .head()
397 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
398 head.peel_to_commit()
399 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))
400 }
401
402 pub fn get_commit(&self, commit_hash: &str) -> Result<git2::Commit<'_>> {
404 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
405
406 self.repo.find_commit(oid).map_err(CascadeError::Git)
407 }
408
409 pub fn get_branch_head(&self, branch_name: &str) -> Result<String> {
411 let branch = self
412 .repo
413 .find_branch(branch_name, git2::BranchType::Local)
414 .map_err(|e| {
415 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
416 })?;
417
418 let commit = branch.get().peel_to_commit().map_err(|e| {
419 CascadeError::branch(format!(
420 "Could not get commit for branch '{branch_name}': {e}"
421 ))
422 })?;
423
424 Ok(commit.id().to_string())
425 }
426
427 fn get_signature(&self) -> Result<Signature<'_>> {
429 if let Ok(config) = self.repo.config() {
431 if let (Ok(name), Ok(email)) = (
432 config.get_string("user.name"),
433 config.get_string("user.email"),
434 ) {
435 return Signature::now(&name, &email).map_err(CascadeError::Git);
436 }
437 }
438
439 Signature::now("Cascade CLI", "cascade@example.com").map_err(CascadeError::Git)
441 }
442
443 fn get_index_tree(&self) -> Result<Oid> {
445 let mut index = self.repo.index().map_err(CascadeError::Git)?;
446
447 index.write_tree().map_err(CascadeError::Git)
448 }
449
450 pub fn get_status(&self) -> Result<git2::Statuses<'_>> {
452 self.repo.statuses(None).map_err(CascadeError::Git)
453 }
454
455 pub fn get_remote_url(&self, name: &str) -> Result<String> {
457 let remote = self.repo.find_remote(name).map_err(CascadeError::Git)?;
458
459 let url = remote.url().ok_or_else(|| {
460 CascadeError::Git(git2::Error::from_str("Remote URL is not valid UTF-8"))
461 })?;
462
463 Ok(url.to_string())
464 }
465
466 pub fn cherry_pick(&self, commit_hash: &str) -> Result<String> {
468 tracing::debug!("Cherry-picking commit {}", commit_hash);
469
470 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
471 let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
472
473 let commit_tree = commit.tree().map_err(CascadeError::Git)?;
475
476 let parent_commit = if commit.parent_count() > 0 {
478 commit.parent(0).map_err(CascadeError::Git)?
479 } else {
480 let empty_tree_oid = self.repo.treebuilder(None)?.write()?;
482 let empty_tree = self.repo.find_tree(empty_tree_oid)?;
483 let sig = self.get_signature()?;
484 return self
485 .repo
486 .commit(
487 Some("HEAD"),
488 &sig,
489 &sig,
490 commit.message().unwrap_or("Cherry-picked commit"),
491 &empty_tree,
492 &[],
493 )
494 .map(|oid| oid.to_string())
495 .map_err(CascadeError::Git);
496 };
497
498 let parent_tree = parent_commit.tree().map_err(CascadeError::Git)?;
499
500 let head_commit = self.get_head_commit()?;
502 let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
503
504 let mut index = self
506 .repo
507 .merge_trees(&parent_tree, &head_tree, &commit_tree, None)
508 .map_err(CascadeError::Git)?;
509
510 if index.has_conflicts() {
512 return Err(CascadeError::branch(format!(
513 "Cherry-pick of {commit_hash} has conflicts that need manual resolution"
514 )));
515 }
516
517 let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
519 let merged_tree = self
520 .repo
521 .find_tree(merged_tree_oid)
522 .map_err(CascadeError::Git)?;
523
524 let signature = self.get_signature()?;
526 let message = format!("Cherry-pick: {}", commit.message().unwrap_or(""));
527
528 let new_commit_oid = self
529 .repo
530 .commit(
531 Some("HEAD"),
532 &signature,
533 &signature,
534 &message,
535 &merged_tree,
536 &[&head_commit],
537 )
538 .map_err(CascadeError::Git)?;
539
540 tracing::info!("Cherry-picked {} -> {}", commit_hash, new_commit_oid);
541 Ok(new_commit_oid.to_string())
542 }
543
544 pub fn has_conflicts(&self) -> Result<bool> {
546 let index = self.repo.index().map_err(CascadeError::Git)?;
547 Ok(index.has_conflicts())
548 }
549
550 pub fn get_conflicted_files(&self) -> Result<Vec<String>> {
552 let index = self.repo.index().map_err(CascadeError::Git)?;
553
554 let mut conflicts = Vec::new();
555
556 let conflict_iter = index.conflicts().map_err(CascadeError::Git)?;
558
559 for conflict in conflict_iter {
560 let conflict = conflict.map_err(CascadeError::Git)?;
561 if let Some(our) = conflict.our {
562 if let Ok(path) = std::str::from_utf8(&our.path) {
563 conflicts.push(path.to_string());
564 }
565 } else if let Some(their) = conflict.their {
566 if let Ok(path) = std::str::from_utf8(&their.path) {
567 conflicts.push(path.to_string());
568 }
569 }
570 }
571
572 Ok(conflicts)
573 }
574
575 pub fn fetch(&self) -> Result<()> {
577 tracing::info!("Fetching from origin");
578
579 let mut remote = self
580 .repo
581 .find_remote("origin")
582 .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
583
584 remote
586 .fetch::<&str>(&[], None, None)
587 .map_err(CascadeError::Git)?;
588
589 tracing::debug!("Fetch completed successfully");
590 Ok(())
591 }
592
593 pub fn pull(&self, branch: &str) -> Result<()> {
595 tracing::info!("Pulling branch: {}", branch);
596
597 self.fetch()?;
599
600 let remote_branch_name = format!("origin/{branch}");
602 let remote_oid = self
603 .repo
604 .refname_to_id(&format!("refs/remotes/{remote_branch_name}"))
605 .map_err(|e| {
606 CascadeError::branch(format!("Remote branch {remote_branch_name} not found: {e}"))
607 })?;
608
609 let remote_commit = self
610 .repo
611 .find_commit(remote_oid)
612 .map_err(CascadeError::Git)?;
613
614 let head_commit = self.get_head_commit()?;
616
617 if head_commit.id() == remote_commit.id() {
619 tracing::debug!("Already up to date");
620 return Ok(());
621 }
622
623 let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
625 let remote_tree = remote_commit.tree().map_err(CascadeError::Git)?;
626
627 let merge_base_oid = self
629 .repo
630 .merge_base(head_commit.id(), remote_commit.id())
631 .map_err(CascadeError::Git)?;
632 let merge_base_commit = self
633 .repo
634 .find_commit(merge_base_oid)
635 .map_err(CascadeError::Git)?;
636 let merge_base_tree = merge_base_commit.tree().map_err(CascadeError::Git)?;
637
638 let mut index = self
640 .repo
641 .merge_trees(&merge_base_tree, &head_tree, &remote_tree, None)
642 .map_err(CascadeError::Git)?;
643
644 if index.has_conflicts() {
645 return Err(CascadeError::branch(
646 "Pull has conflicts that need manual resolution".to_string(),
647 ));
648 }
649
650 let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
652 let merged_tree = self
653 .repo
654 .find_tree(merged_tree_oid)
655 .map_err(CascadeError::Git)?;
656
657 let signature = self.get_signature()?;
658 let message = format!("Merge branch '{branch}' from origin");
659
660 self.repo
661 .commit(
662 Some("HEAD"),
663 &signature,
664 &signature,
665 &message,
666 &merged_tree,
667 &[&head_commit, &remote_commit],
668 )
669 .map_err(CascadeError::Git)?;
670
671 tracing::info!("Pull completed successfully");
672 Ok(())
673 }
674
675 pub fn push(&self, branch: &str) -> Result<()> {
677 tracing::info!("Pushing branch: {}", branch);
678
679 let mut remote = self
680 .repo
681 .find_remote("origin")
682 .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
683
684 let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
685
686 remote.push(&[&refspec], None).map_err(CascadeError::Git)?;
687
688 tracing::info!("Push completed successfully");
689 Ok(())
690 }
691
692 pub fn delete_branch(&self, name: &str) -> Result<()> {
694 self.delete_branch_with_options(name, false)
695 }
696
697 pub fn delete_branch_unsafe(&self, name: &str) -> Result<()> {
699 self.delete_branch_with_options(name, true)
700 }
701
702 fn delete_branch_with_options(&self, name: &str, force_unsafe: bool) -> Result<()> {
704 info!("Attempting to delete branch: {}", name);
705
706 if !force_unsafe {
708 let safety_result = self.check_branch_deletion_safety(name)?;
709 if let Some(safety_info) = safety_result {
710 self.handle_branch_deletion_confirmation(name, &safety_info)?;
712 }
713 }
714
715 let mut branch = self
716 .repo
717 .find_branch(name, git2::BranchType::Local)
718 .map_err(|e| CascadeError::branch(format!("Could not find branch '{name}': {e}")))?;
719
720 branch
721 .delete()
722 .map_err(|e| CascadeError::branch(format!("Could not delete branch '{name}': {e}")))?;
723
724 info!("Successfully deleted branch '{}'", name);
725 Ok(())
726 }
727
728 pub fn get_commits_between(&self, from: &str, to: &str) -> Result<Vec<git2::Commit<'_>>> {
730 let from_oid = self
731 .repo
732 .refname_to_id(&format!("refs/heads/{from}"))
733 .or_else(|_| Oid::from_str(from))
734 .map_err(|e| CascadeError::branch(format!("Invalid from reference '{from}': {e}")))?;
735
736 let to_oid = self
737 .repo
738 .refname_to_id(&format!("refs/heads/{to}"))
739 .or_else(|_| Oid::from_str(to))
740 .map_err(|e| CascadeError::branch(format!("Invalid to reference '{to}': {e}")))?;
741
742 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
743
744 revwalk.push(to_oid).map_err(CascadeError::Git)?;
745 revwalk.hide(from_oid).map_err(CascadeError::Git)?;
746
747 let mut commits = Vec::new();
748 for oid in revwalk {
749 let oid = oid.map_err(CascadeError::Git)?;
750 let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
751 commits.push(commit);
752 }
753
754 Ok(commits)
755 }
756
757 pub fn force_push_branch(&self, target_branch: &str, source_branch: &str) -> Result<()> {
760 self.force_push_branch_with_options(target_branch, source_branch, false)
761 }
762
763 pub fn force_push_branch_unsafe(&self, target_branch: &str, source_branch: &str) -> Result<()> {
765 self.force_push_branch_with_options(target_branch, source_branch, true)
766 }
767
768 fn force_push_branch_with_options(
770 &self,
771 target_branch: &str,
772 source_branch: &str,
773 force_unsafe: bool,
774 ) -> Result<()> {
775 info!(
776 "Force pushing {} content to {} to preserve PR history",
777 source_branch, target_branch
778 );
779
780 if !force_unsafe {
782 let safety_result = self.check_force_push_safety_enhanced(target_branch)?;
783 if let Some(backup_info) = safety_result {
784 self.create_backup_branch(target_branch, &backup_info.remote_commit_id)?;
786 info!(
787 "✅ Created backup branch: {}",
788 backup_info.backup_branch_name
789 );
790 }
791 }
792
793 let source_ref = self
795 .repo
796 .find_reference(&format!("refs/heads/{source_branch}"))
797 .map_err(|e| {
798 CascadeError::config(format!("Failed to find source branch {source_branch}: {e}"))
799 })?;
800 let source_commit = source_ref.peel_to_commit().map_err(|e| {
801 CascadeError::config(format!(
802 "Failed to get commit for source branch {source_branch}: {e}"
803 ))
804 })?;
805
806 let mut target_ref = self
808 .repo
809 .find_reference(&format!("refs/heads/{target_branch}"))
810 .map_err(|e| {
811 CascadeError::config(format!("Failed to find target branch {target_branch}: {e}"))
812 })?;
813
814 target_ref
815 .set_target(source_commit.id(), "Force push from rebase")
816 .map_err(|e| {
817 CascadeError::config(format!(
818 "Failed to update target branch {target_branch}: {e}"
819 ))
820 })?;
821
822 let mut remote = self
824 .repo
825 .find_remote("origin")
826 .map_err(|e| CascadeError::config(format!("Failed to find origin remote: {e}")))?;
827
828 let refspec = format!("+refs/heads/{target_branch}:refs/heads/{target_branch}");
829
830 let mut callbacks = git2::RemoteCallbacks::new();
832
833 callbacks.credentials(|_url, username_from_url, _allowed_types| {
835 if let Some(username) = username_from_url {
836 git2::Cred::ssh_key_from_agent(username)
838 } else {
839 git2::Cred::default()
841 }
842 });
843
844 let mut push_options = git2::PushOptions::new();
846 push_options.remote_callbacks(callbacks);
847
848 remote
849 .push(&[&refspec], Some(&mut push_options))
850 .map_err(|e| {
851 CascadeError::config(format!("Failed to force push {target_branch}: {e}"))
852 })?;
853
854 info!(
855 "✅ Successfully force pushed {} to preserve PR history",
856 target_branch
857 );
858 Ok(())
859 }
860
861 fn check_force_push_safety_enhanced(
864 &self,
865 target_branch: &str,
866 ) -> Result<Option<ForceBackupInfo>> {
867 match self.fetch() {
869 Ok(_) => {}
870 Err(e) => {
871 warn!("Could not fetch latest changes for safety check: {}", e);
873 }
874 }
875
876 let remote_ref = format!("refs/remotes/origin/{target_branch}");
878 let local_ref = format!("refs/heads/{target_branch}");
879
880 let local_commit = match self.repo.find_reference(&local_ref) {
882 Ok(reference) => reference.peel_to_commit().ok(),
883 Err(_) => None,
884 };
885
886 let remote_commit = match self.repo.find_reference(&remote_ref) {
887 Ok(reference) => reference.peel_to_commit().ok(),
888 Err(_) => None,
889 };
890
891 if let (Some(local), Some(remote)) = (local_commit, remote_commit) {
893 if local.id() != remote.id() {
894 let merge_base_oid = self
896 .repo
897 .merge_base(local.id(), remote.id())
898 .map_err(|e| CascadeError::config(format!("Failed to find merge base: {e}")))?;
899
900 if merge_base_oid != remote.id() {
902 let commits_to_lose = self.count_commits_between(
903 &merge_base_oid.to_string(),
904 &remote.id().to_string(),
905 )?;
906
907 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
909 let backup_branch_name = format!("{target_branch}_backup_{timestamp}");
910
911 warn!(
912 "⚠️ Force push to '{}' would overwrite {} commits on remote",
913 target_branch, commits_to_lose
914 );
915
916 if std::env::var("CI").is_ok() || std::env::var("FORCE_PUSH_NO_CONFIRM").is_ok()
918 {
919 info!(
920 "Non-interactive environment detected, proceeding with backup creation"
921 );
922 return Ok(Some(ForceBackupInfo {
923 backup_branch_name,
924 remote_commit_id: remote.id().to_string(),
925 commits_that_would_be_lost: commits_to_lose,
926 }));
927 }
928
929 println!("\n⚠️ FORCE PUSH WARNING ⚠️");
931 println!("Force push to '{target_branch}' would overwrite {commits_to_lose} commits on remote:");
932
933 match self
935 .get_commits_between(&merge_base_oid.to_string(), &remote.id().to_string())
936 {
937 Ok(commits) => {
938 println!("\nCommits that would be lost:");
939 for (i, commit) in commits.iter().take(5).enumerate() {
940 let short_hash = &commit.id().to_string()[..8];
941 let summary = commit.summary().unwrap_or("<no message>");
942 println!(" {}. {} - {}", i + 1, short_hash, summary);
943 }
944 if commits.len() > 5 {
945 println!(" ... and {} more commits", commits.len() - 5);
946 }
947 }
948 Err(_) => {
949 println!(" (Unable to retrieve commit details)");
950 }
951 }
952
953 println!("\nA backup branch '{backup_branch_name}' will be created before proceeding.");
954
955 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
956 .with_prompt("Do you want to proceed with the force push?")
957 .default(false)
958 .interact()
959 .map_err(|e| {
960 CascadeError::config(format!("Failed to get user confirmation: {e}"))
961 })?;
962
963 if !confirmed {
964 return Err(CascadeError::config(
965 "Force push cancelled by user. Use --force to bypass this check."
966 .to_string(),
967 ));
968 }
969
970 return Ok(Some(ForceBackupInfo {
971 backup_branch_name,
972 remote_commit_id: remote.id().to_string(),
973 commits_that_would_be_lost: commits_to_lose,
974 }));
975 }
976 }
977 }
978
979 Ok(None)
980 }
981
982 fn create_backup_branch(&self, original_branch: &str, remote_commit_id: &str) -> Result<()> {
984 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
985 let backup_branch_name = format!("{original_branch}_backup_{timestamp}");
986
987 let commit_oid = Oid::from_str(remote_commit_id).map_err(|e| {
989 CascadeError::config(format!("Invalid commit ID {remote_commit_id}: {e}"))
990 })?;
991
992 let commit = self.repo.find_commit(commit_oid).map_err(|e| {
994 CascadeError::config(format!("Failed to find commit {remote_commit_id}: {e}"))
995 })?;
996
997 self.repo
999 .branch(&backup_branch_name, &commit, false)
1000 .map_err(|e| {
1001 CascadeError::config(format!(
1002 "Failed to create backup branch {backup_branch_name}: {e}"
1003 ))
1004 })?;
1005
1006 info!(
1007 "✅ Created backup branch '{}' pointing to {}",
1008 backup_branch_name,
1009 &remote_commit_id[..8]
1010 );
1011 Ok(())
1012 }
1013
1014 fn check_branch_deletion_safety(
1017 &self,
1018 branch_name: &str,
1019 ) -> Result<Option<BranchDeletionSafety>> {
1020 match self.fetch() {
1022 Ok(_) => {}
1023 Err(e) => {
1024 warn!(
1025 "Could not fetch latest changes for branch deletion safety check: {}",
1026 e
1027 );
1028 }
1029 }
1030
1031 let branch = self
1033 .repo
1034 .find_branch(branch_name, git2::BranchType::Local)
1035 .map_err(|e| {
1036 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
1037 })?;
1038
1039 let _branch_commit = branch.get().peel_to_commit().map_err(|e| {
1040 CascadeError::branch(format!(
1041 "Could not get commit for branch '{branch_name}': {e}"
1042 ))
1043 })?;
1044
1045 let main_branch_name = self.detect_main_branch()?;
1047
1048 let is_merged_to_main = self.is_branch_merged_to_main(branch_name, &main_branch_name)?;
1050
1051 let remote_tracking_branch = self.get_remote_tracking_branch(branch_name);
1053
1054 let mut unpushed_commits = Vec::new();
1055
1056 if let Some(ref remote_branch) = remote_tracking_branch {
1058 match self.get_commits_between(remote_branch, branch_name) {
1059 Ok(commits) => {
1060 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1061 }
1062 Err(_) => {
1063 if !is_merged_to_main {
1065 if let Ok(commits) =
1066 self.get_commits_between(&main_branch_name, branch_name)
1067 {
1068 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1069 }
1070 }
1071 }
1072 }
1073 } else if !is_merged_to_main {
1074 if let Ok(commits) = self.get_commits_between(&main_branch_name, branch_name) {
1076 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1077 }
1078 }
1079
1080 if !unpushed_commits.is_empty() || (!is_merged_to_main && remote_tracking_branch.is_none())
1082 {
1083 Ok(Some(BranchDeletionSafety {
1084 unpushed_commits,
1085 remote_tracking_branch,
1086 is_merged_to_main,
1087 main_branch_name,
1088 }))
1089 } else {
1090 Ok(None)
1091 }
1092 }
1093
1094 fn handle_branch_deletion_confirmation(
1096 &self,
1097 branch_name: &str,
1098 safety_info: &BranchDeletionSafety,
1099 ) -> Result<()> {
1100 if std::env::var("CI").is_ok() || std::env::var("BRANCH_DELETE_NO_CONFIRM").is_ok() {
1102 return Err(CascadeError::branch(
1103 format!(
1104 "Branch '{branch_name}' has {} unpushed commits and cannot be deleted in non-interactive mode. Use --force to override.",
1105 safety_info.unpushed_commits.len()
1106 )
1107 ));
1108 }
1109
1110 println!("\n⚠️ BRANCH DELETION WARNING ⚠️");
1112 println!("Branch '{branch_name}' has potential issues:");
1113
1114 if !safety_info.unpushed_commits.is_empty() {
1115 println!(
1116 "\n🔍 Unpushed commits ({} total):",
1117 safety_info.unpushed_commits.len()
1118 );
1119
1120 for (i, commit_id) in safety_info.unpushed_commits.iter().take(5).enumerate() {
1122 if let Ok(commit) = self.repo.find_commit(Oid::from_str(commit_id).unwrap()) {
1123 let short_hash = &commit_id[..8];
1124 let summary = commit.summary().unwrap_or("<no message>");
1125 println!(" {}. {} - {}", i + 1, short_hash, summary);
1126 }
1127 }
1128
1129 if safety_info.unpushed_commits.len() > 5 {
1130 println!(
1131 " ... and {} more commits",
1132 safety_info.unpushed_commits.len() - 5
1133 );
1134 }
1135 }
1136
1137 if !safety_info.is_merged_to_main {
1138 println!("\n📋 Branch status:");
1139 println!(" • Not merged to '{}'", safety_info.main_branch_name);
1140 if let Some(ref remote) = safety_info.remote_tracking_branch {
1141 println!(" • Remote tracking branch: {remote}");
1142 } else {
1143 println!(" • No remote tracking branch");
1144 }
1145 }
1146
1147 println!("\n💡 Safer alternatives:");
1148 if !safety_info.unpushed_commits.is_empty() {
1149 if let Some(ref _remote) = safety_info.remote_tracking_branch {
1150 println!(" • Push commits first: git push origin {branch_name}");
1151 } else {
1152 println!(" • Create and push to remote: git push -u origin {branch_name}");
1153 }
1154 }
1155 if !safety_info.is_merged_to_main {
1156 println!(
1157 " • Merge to {} first: git checkout {} && git merge {branch_name}",
1158 safety_info.main_branch_name, safety_info.main_branch_name
1159 );
1160 }
1161
1162 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
1163 .with_prompt("Do you want to proceed with deleting this branch?")
1164 .default(false)
1165 .interact()
1166 .map_err(|e| CascadeError::branch(format!("Failed to get user confirmation: {e}")))?;
1167
1168 if !confirmed {
1169 return Err(CascadeError::branch(
1170 "Branch deletion cancelled by user. Use --force to bypass this check.".to_string(),
1171 ));
1172 }
1173
1174 Ok(())
1175 }
1176
1177 fn detect_main_branch(&self) -> Result<String> {
1179 let main_candidates = ["main", "master", "develop", "trunk"];
1180
1181 for candidate in &main_candidates {
1182 if self
1183 .repo
1184 .find_branch(candidate, git2::BranchType::Local)
1185 .is_ok()
1186 {
1187 return Ok(candidate.to_string());
1188 }
1189 }
1190
1191 if let Ok(head) = self.repo.head() {
1193 if let Some(name) = head.shorthand() {
1194 return Ok(name.to_string());
1195 }
1196 }
1197
1198 Ok("main".to_string())
1200 }
1201
1202 fn is_branch_merged_to_main(&self, branch_name: &str, main_branch: &str) -> Result<bool> {
1204 match self.get_commits_between(main_branch, branch_name) {
1206 Ok(commits) => Ok(commits.is_empty()),
1207 Err(_) => {
1208 Ok(false)
1210 }
1211 }
1212 }
1213
1214 fn get_remote_tracking_branch(&self, branch_name: &str) -> Option<String> {
1216 let remote_candidates = [
1218 format!("origin/{branch_name}"),
1219 format!("remotes/origin/{branch_name}"),
1220 ];
1221
1222 for candidate in &remote_candidates {
1223 if self
1224 .repo
1225 .find_reference(&format!(
1226 "refs/remotes/{}",
1227 candidate.replace("remotes/", "")
1228 ))
1229 .is_ok()
1230 {
1231 return Some(candidate.clone());
1232 }
1233 }
1234
1235 None
1236 }
1237
1238 fn check_checkout_safety(&self, _target: &str) -> Result<Option<CheckoutSafety>> {
1240 let is_dirty = self.is_dirty()?;
1242 if !is_dirty {
1243 return Ok(None);
1245 }
1246
1247 let current_branch = self.get_current_branch().ok();
1249
1250 let modified_files = self.get_modified_files()?;
1252 let staged_files = self.get_staged_files()?;
1253 let untracked_files = self.get_untracked_files()?;
1254
1255 let has_uncommitted_changes = !modified_files.is_empty() || !staged_files.is_empty();
1256
1257 if has_uncommitted_changes || !untracked_files.is_empty() {
1258 return Ok(Some(CheckoutSafety {
1259 has_uncommitted_changes,
1260 modified_files,
1261 staged_files,
1262 untracked_files,
1263 stash_created: None,
1264 current_branch,
1265 }));
1266 }
1267
1268 Ok(None)
1269 }
1270
1271 fn handle_checkout_confirmation(
1273 &self,
1274 target: &str,
1275 safety_info: &CheckoutSafety,
1276 ) -> Result<()> {
1277 if std::env::var("CI").is_ok() || std::env::var("CHECKOUT_NO_CONFIRM").is_ok() {
1279 return Err(CascadeError::branch(
1280 format!(
1281 "Cannot checkout '{target}' with uncommitted changes in non-interactive mode. Commit your changes or use stash first."
1282 )
1283 ));
1284 }
1285
1286 println!("\n⚠️ CHECKOUT WARNING ⚠️");
1288 println!("You have uncommitted changes that could be lost:");
1289
1290 if !safety_info.modified_files.is_empty() {
1291 println!(
1292 "\n📝 Modified files ({}):",
1293 safety_info.modified_files.len()
1294 );
1295 for file in safety_info.modified_files.iter().take(10) {
1296 println!(" - {file}");
1297 }
1298 if safety_info.modified_files.len() > 10 {
1299 println!(" ... and {} more", safety_info.modified_files.len() - 10);
1300 }
1301 }
1302
1303 if !safety_info.staged_files.is_empty() {
1304 println!("\n📁 Staged files ({}):", safety_info.staged_files.len());
1305 for file in safety_info.staged_files.iter().take(10) {
1306 println!(" - {file}");
1307 }
1308 if safety_info.staged_files.len() > 10 {
1309 println!(" ... and {} more", safety_info.staged_files.len() - 10);
1310 }
1311 }
1312
1313 if !safety_info.untracked_files.is_empty() {
1314 println!(
1315 "\n❓ Untracked files ({}):",
1316 safety_info.untracked_files.len()
1317 );
1318 for file in safety_info.untracked_files.iter().take(5) {
1319 println!(" - {file}");
1320 }
1321 if safety_info.untracked_files.len() > 5 {
1322 println!(" ... and {} more", safety_info.untracked_files.len() - 5);
1323 }
1324 }
1325
1326 println!("\n🔄 Options:");
1327 println!("1. Stash changes and checkout (recommended)");
1328 println!("2. Force checkout (WILL LOSE UNCOMMITTED CHANGES)");
1329 println!("3. Cancel checkout");
1330
1331 let confirmation = Confirm::with_theme(&ColorfulTheme::default())
1332 .with_prompt("Would you like to stash your changes and proceed with checkout?")
1333 .interact()
1334 .map_err(|e| CascadeError::branch(format!("Could not get user confirmation: {e}")))?;
1335
1336 if confirmation {
1337 let stash_message = format!(
1339 "Auto-stash before checkout to {} at {}",
1340 target,
1341 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
1342 );
1343
1344 match self.create_stash(&stash_message) {
1345 Ok(stash_oid) => {
1346 println!("✅ Created stash: {stash_message} ({stash_oid})");
1347 println!("💡 You can restore with: git stash pop");
1348 }
1349 Err(e) => {
1350 println!("❌ Failed to create stash: {e}");
1351
1352 let force_confirm = Confirm::with_theme(&ColorfulTheme::default())
1353 .with_prompt("Stash failed. Force checkout anyway? (WILL LOSE CHANGES)")
1354 .interact()
1355 .map_err(|e| {
1356 CascadeError::branch(format!("Could not get confirmation: {e}"))
1357 })?;
1358
1359 if !force_confirm {
1360 return Err(CascadeError::branch(
1361 "Checkout cancelled by user".to_string(),
1362 ));
1363 }
1364 }
1365 }
1366 } else {
1367 return Err(CascadeError::branch(
1368 "Checkout cancelled by user".to_string(),
1369 ));
1370 }
1371
1372 Ok(())
1373 }
1374
1375 fn create_stash(&self, message: &str) -> Result<String> {
1377 warn!("Automatic stashing not yet implemented - please stash manually");
1381 Err(CascadeError::branch(format!(
1382 "Please manually stash your changes first: git stash push -m \"{message}\""
1383 )))
1384 }
1385
1386 fn get_modified_files(&self) -> Result<Vec<String>> {
1388 let mut opts = git2::StatusOptions::new();
1389 opts.include_untracked(false).include_ignored(false);
1390
1391 let statuses = self
1392 .repo
1393 .statuses(Some(&mut opts))
1394 .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
1395
1396 let mut modified_files = Vec::new();
1397 for status in statuses.iter() {
1398 let flags = status.status();
1399 if flags.contains(git2::Status::WT_MODIFIED) || flags.contains(git2::Status::WT_DELETED)
1400 {
1401 if let Some(path) = status.path() {
1402 modified_files.push(path.to_string());
1403 }
1404 }
1405 }
1406
1407 Ok(modified_files)
1408 }
1409
1410 fn get_staged_files(&self) -> Result<Vec<String>> {
1412 let mut opts = git2::StatusOptions::new();
1413 opts.include_untracked(false).include_ignored(false);
1414
1415 let statuses = self
1416 .repo
1417 .statuses(Some(&mut opts))
1418 .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
1419
1420 let mut staged_files = Vec::new();
1421 for status in statuses.iter() {
1422 let flags = status.status();
1423 if flags.contains(git2::Status::INDEX_MODIFIED)
1424 || flags.contains(git2::Status::INDEX_NEW)
1425 || flags.contains(git2::Status::INDEX_DELETED)
1426 {
1427 if let Some(path) = status.path() {
1428 staged_files.push(path.to_string());
1429 }
1430 }
1431 }
1432
1433 Ok(staged_files)
1434 }
1435
1436 fn count_commits_between(&self, from: &str, to: &str) -> Result<usize> {
1438 let commits = self.get_commits_between(from, to)?;
1439 Ok(commits.len())
1440 }
1441
1442 pub fn resolve_reference(&self, reference: &str) -> Result<git2::Commit<'_>> {
1444 if let Ok(oid) = Oid::from_str(reference) {
1446 if let Ok(commit) = self.repo.find_commit(oid) {
1447 return Ok(commit);
1448 }
1449 }
1450
1451 let obj = self.repo.revparse_single(reference).map_err(|e| {
1453 CascadeError::branch(format!("Could not resolve reference '{reference}': {e}"))
1454 })?;
1455
1456 obj.peel_to_commit().map_err(|e| {
1457 CascadeError::branch(format!(
1458 "Reference '{reference}' does not point to a commit: {e}"
1459 ))
1460 })
1461 }
1462
1463 pub fn reset_soft(&self, target_ref: &str) -> Result<()> {
1465 let target_commit = self.resolve_reference(target_ref)?;
1466
1467 self.repo
1468 .reset(target_commit.as_object(), git2::ResetType::Soft, None)
1469 .map_err(CascadeError::Git)?;
1470
1471 Ok(())
1472 }
1473
1474 pub fn find_branch_containing_commit(&self, commit_hash: &str) -> Result<String> {
1476 let oid = Oid::from_str(commit_hash).map_err(|e| {
1477 CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
1478 })?;
1479
1480 let branches = self
1482 .repo
1483 .branches(Some(git2::BranchType::Local))
1484 .map_err(CascadeError::Git)?;
1485
1486 for branch_result in branches {
1487 let (branch, _) = branch_result.map_err(CascadeError::Git)?;
1488
1489 if let Some(branch_name) = branch.name().map_err(CascadeError::Git)? {
1490 if let Ok(branch_head) = branch.get().peel_to_commit() {
1492 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
1494 revwalk.push(branch_head.id()).map_err(CascadeError::Git)?;
1495
1496 for commit_oid in revwalk {
1497 let commit_oid = commit_oid.map_err(CascadeError::Git)?;
1498 if commit_oid == oid {
1499 return Ok(branch_name.to_string());
1500 }
1501 }
1502 }
1503 }
1504 }
1505
1506 Err(CascadeError::branch(format!(
1508 "Commit {commit_hash} not found in any local branch"
1509 )))
1510 }
1511
1512 pub async fn fetch_async(&self) -> Result<()> {
1516 let repo_path = self.path.clone();
1517 crate::utils::async_ops::run_git_operation(move || {
1518 let repo = GitRepository::open(&repo_path)?;
1519 repo.fetch()
1520 })
1521 .await
1522 }
1523
1524 pub async fn pull_async(&self, branch: &str) -> Result<()> {
1526 let repo_path = self.path.clone();
1527 let branch_name = branch.to_string();
1528 crate::utils::async_ops::run_git_operation(move || {
1529 let repo = GitRepository::open(&repo_path)?;
1530 repo.pull(&branch_name)
1531 })
1532 .await
1533 }
1534
1535 pub async fn push_branch_async(&self, branch_name: &str) -> Result<()> {
1537 let repo_path = self.path.clone();
1538 let branch = branch_name.to_string();
1539 crate::utils::async_ops::run_git_operation(move || {
1540 let repo = GitRepository::open(&repo_path)?;
1541 repo.push(&branch)
1542 })
1543 .await
1544 }
1545
1546 pub async fn cherry_pick_commit_async(&self, commit_hash: &str) -> Result<String> {
1548 let repo_path = self.path.clone();
1549 let hash = commit_hash.to_string();
1550 crate::utils::async_ops::run_git_operation(move || {
1551 let repo = GitRepository::open(&repo_path)?;
1552 repo.cherry_pick(&hash)
1553 })
1554 .await
1555 }
1556
1557 pub async fn get_commit_hashes_between_async(
1559 &self,
1560 from: &str,
1561 to: &str,
1562 ) -> Result<Vec<String>> {
1563 let repo_path = self.path.clone();
1564 let from_str = from.to_string();
1565 let to_str = to.to_string();
1566 crate::utils::async_ops::run_git_operation(move || {
1567 let repo = GitRepository::open(&repo_path)?;
1568 let commits = repo.get_commits_between(&from_str, &to_str)?;
1569 Ok(commits.into_iter().map(|c| c.id().to_string()).collect())
1570 })
1571 .await
1572 }
1573}
1574
1575#[cfg(test)]
1576mod tests {
1577 use super::*;
1578 use std::process::Command;
1579 use tempfile::TempDir;
1580
1581 fn create_test_repo() -> (TempDir, PathBuf) {
1582 let temp_dir = TempDir::new().unwrap();
1583 let repo_path = temp_dir.path().to_path_buf();
1584
1585 Command::new("git")
1587 .args(["init"])
1588 .current_dir(&repo_path)
1589 .output()
1590 .unwrap();
1591 Command::new("git")
1592 .args(["config", "user.name", "Test"])
1593 .current_dir(&repo_path)
1594 .output()
1595 .unwrap();
1596 Command::new("git")
1597 .args(["config", "user.email", "test@test.com"])
1598 .current_dir(&repo_path)
1599 .output()
1600 .unwrap();
1601
1602 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
1604 Command::new("git")
1605 .args(["add", "."])
1606 .current_dir(&repo_path)
1607 .output()
1608 .unwrap();
1609 Command::new("git")
1610 .args(["commit", "-m", "Initial commit"])
1611 .current_dir(&repo_path)
1612 .output()
1613 .unwrap();
1614
1615 (temp_dir, repo_path)
1616 }
1617
1618 fn create_commit(repo_path: &PathBuf, message: &str, filename: &str) {
1619 let file_path = repo_path.join(filename);
1620 std::fs::write(&file_path, format!("Content for {filename}\n")).unwrap();
1621
1622 Command::new("git")
1623 .args(["add", filename])
1624 .current_dir(repo_path)
1625 .output()
1626 .unwrap();
1627 Command::new("git")
1628 .args(["commit", "-m", message])
1629 .current_dir(repo_path)
1630 .output()
1631 .unwrap();
1632 }
1633
1634 #[test]
1635 fn test_repository_info() {
1636 let (_temp_dir, repo_path) = create_test_repo();
1637 let repo = GitRepository::open(&repo_path).unwrap();
1638
1639 let info = repo.get_info().unwrap();
1640 assert!(!info.is_dirty); assert!(
1642 info.head_branch == Some("master".to_string())
1643 || info.head_branch == Some("main".to_string()),
1644 "Expected default branch to be 'master' or 'main', got {:?}",
1645 info.head_branch
1646 );
1647 assert!(info.head_commit.is_some()); assert!(info.untracked_files.is_empty()); }
1650
1651 #[test]
1652 fn test_force_push_branch_basic() {
1653 let (_temp_dir, repo_path) = create_test_repo();
1654 let repo = GitRepository::open(&repo_path).unwrap();
1655
1656 let default_branch = repo.get_current_branch().unwrap();
1658
1659 create_commit(&repo_path, "Feature commit 1", "feature1.rs");
1661 Command::new("git")
1662 .args(["checkout", "-b", "source-branch"])
1663 .current_dir(&repo_path)
1664 .output()
1665 .unwrap();
1666 create_commit(&repo_path, "Feature commit 2", "feature2.rs");
1667
1668 Command::new("git")
1670 .args(["checkout", &default_branch])
1671 .current_dir(&repo_path)
1672 .output()
1673 .unwrap();
1674 Command::new("git")
1675 .args(["checkout", "-b", "target-branch"])
1676 .current_dir(&repo_path)
1677 .output()
1678 .unwrap();
1679 create_commit(&repo_path, "Target commit", "target.rs");
1680
1681 let result = repo.force_push_branch("target-branch", "source-branch");
1683
1684 assert!(result.is_ok() || result.is_err()); }
1688
1689 #[test]
1690 fn test_force_push_branch_nonexistent_branches() {
1691 let (_temp_dir, repo_path) = create_test_repo();
1692 let repo = GitRepository::open(&repo_path).unwrap();
1693
1694 let default_branch = repo.get_current_branch().unwrap();
1696
1697 let result = repo.force_push_branch("target", "nonexistent-source");
1699 assert!(result.is_err());
1700
1701 let result = repo.force_push_branch("nonexistent-target", &default_branch);
1703 assert!(result.is_err());
1704 }
1705
1706 #[test]
1707 fn test_force_push_workflow_simulation() {
1708 let (_temp_dir, repo_path) = create_test_repo();
1709 let repo = GitRepository::open(&repo_path).unwrap();
1710
1711 Command::new("git")
1714 .args(["checkout", "-b", "feature-auth"])
1715 .current_dir(&repo_path)
1716 .output()
1717 .unwrap();
1718 create_commit(&repo_path, "Add authentication", "auth.rs");
1719
1720 Command::new("git")
1722 .args(["checkout", "-b", "feature-auth-v2"])
1723 .current_dir(&repo_path)
1724 .output()
1725 .unwrap();
1726 create_commit(&repo_path, "Fix auth validation", "auth.rs");
1727
1728 let result = repo.force_push_branch("feature-auth", "feature-auth-v2");
1730
1731 match result {
1733 Ok(_) => {
1734 Command::new("git")
1736 .args(["checkout", "feature-auth"])
1737 .current_dir(&repo_path)
1738 .output()
1739 .unwrap();
1740 let log_output = Command::new("git")
1741 .args(["log", "--oneline", "-2"])
1742 .current_dir(&repo_path)
1743 .output()
1744 .unwrap();
1745 let log_str = String::from_utf8_lossy(&log_output.stdout);
1746 assert!(
1747 log_str.contains("Fix auth validation")
1748 || log_str.contains("Add authentication")
1749 );
1750 }
1751 Err(_) => {
1752 }
1755 }
1756 }
1757
1758 #[test]
1759 fn test_branch_operations() {
1760 let (_temp_dir, repo_path) = create_test_repo();
1761 let repo = GitRepository::open(&repo_path).unwrap();
1762
1763 let current = repo.get_current_branch().unwrap();
1765 assert!(
1766 current == "master" || current == "main",
1767 "Expected default branch to be 'master' or 'main', got '{current}'"
1768 );
1769
1770 Command::new("git")
1772 .args(["checkout", "-b", "test-branch"])
1773 .current_dir(&repo_path)
1774 .output()
1775 .unwrap();
1776 let current = repo.get_current_branch().unwrap();
1777 assert_eq!(current, "test-branch");
1778 }
1779
1780 #[test]
1781 fn test_commit_operations() {
1782 let (_temp_dir, repo_path) = create_test_repo();
1783 let repo = GitRepository::open(&repo_path).unwrap();
1784
1785 let head = repo.get_head_commit().unwrap();
1787 assert_eq!(head.message().unwrap().trim(), "Initial commit");
1788
1789 let hash = head.id().to_string();
1791 let same_commit = repo.get_commit(&hash).unwrap();
1792 assert_eq!(head.id(), same_commit.id());
1793 }
1794
1795 #[test]
1796 fn test_checkout_safety_clean_repo() {
1797 let (_temp_dir, repo_path) = create_test_repo();
1798 let repo = GitRepository::open(&repo_path).unwrap();
1799
1800 create_commit(&repo_path, "Second commit", "test.txt");
1802 Command::new("git")
1803 .args(["checkout", "-b", "test-branch"])
1804 .current_dir(&repo_path)
1805 .output()
1806 .unwrap();
1807
1808 let safety_result = repo.check_checkout_safety("main");
1810 assert!(safety_result.is_ok());
1811 assert!(safety_result.unwrap().is_none()); }
1813
1814 #[test]
1815 fn test_checkout_safety_with_modified_files() {
1816 let (_temp_dir, repo_path) = create_test_repo();
1817 let repo = GitRepository::open(&repo_path).unwrap();
1818
1819 Command::new("git")
1821 .args(["checkout", "-b", "test-branch"])
1822 .current_dir(&repo_path)
1823 .output()
1824 .unwrap();
1825
1826 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
1828
1829 let safety_result = repo.check_checkout_safety("main");
1831 assert!(safety_result.is_ok());
1832 let safety_info = safety_result.unwrap();
1833 assert!(safety_info.is_some());
1834
1835 let info = safety_info.unwrap();
1836 assert!(!info.modified_files.is_empty());
1837 assert!(info.modified_files.contains(&"README.md".to_string()));
1838 }
1839
1840 #[test]
1841 fn test_unsafe_checkout_methods() {
1842 let (_temp_dir, repo_path) = create_test_repo();
1843 let repo = GitRepository::open(&repo_path).unwrap();
1844
1845 create_commit(&repo_path, "Second commit", "test.txt");
1847 Command::new("git")
1848 .args(["checkout", "-b", "test-branch"])
1849 .current_dir(&repo_path)
1850 .output()
1851 .unwrap();
1852
1853 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
1855
1856 let _result = repo.checkout_branch_unsafe("master");
1858 let head_commit = repo.get_head_commit().unwrap();
1863 let commit_hash = head_commit.id().to_string();
1864 let _result = repo.checkout_commit_unsafe(&commit_hash);
1865 }
1867
1868 #[test]
1869 fn test_get_modified_files() {
1870 let (_temp_dir, repo_path) = create_test_repo();
1871 let repo = GitRepository::open(&repo_path).unwrap();
1872
1873 let modified = repo.get_modified_files().unwrap();
1875 assert!(modified.is_empty());
1876
1877 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
1879
1880 let modified = repo.get_modified_files().unwrap();
1882 assert_eq!(modified.len(), 1);
1883 assert!(modified.contains(&"README.md".to_string()));
1884 }
1885
1886 #[test]
1887 fn test_get_staged_files() {
1888 let (_temp_dir, repo_path) = create_test_repo();
1889 let repo = GitRepository::open(&repo_path).unwrap();
1890
1891 let staged = repo.get_staged_files().unwrap();
1893 assert!(staged.is_empty());
1894
1895 std::fs::write(repo_path.join("staged.txt"), "Staged content").unwrap();
1897 Command::new("git")
1898 .args(["add", "staged.txt"])
1899 .current_dir(&repo_path)
1900 .output()
1901 .unwrap();
1902
1903 let staged = repo.get_staged_files().unwrap();
1905 assert_eq!(staged.len(), 1);
1906 assert!(staged.contains(&"staged.txt".to_string()));
1907 }
1908
1909 #[test]
1910 fn test_create_stash_fallback() {
1911 let (_temp_dir, repo_path) = create_test_repo();
1912 let repo = GitRepository::open(&repo_path).unwrap();
1913
1914 let result = repo.create_stash("test stash");
1916 assert!(result.is_err());
1917 let error_msg = result.unwrap_err().to_string();
1918 assert!(error_msg.contains("git stash push"));
1919 }
1920
1921 #[test]
1922 fn test_delete_branch_unsafe() {
1923 let (_temp_dir, repo_path) = create_test_repo();
1924 let repo = GitRepository::open(&repo_path).unwrap();
1925
1926 create_commit(&repo_path, "Second commit", "test.txt");
1928 Command::new("git")
1929 .args(["checkout", "-b", "test-branch"])
1930 .current_dir(&repo_path)
1931 .output()
1932 .unwrap();
1933
1934 create_commit(&repo_path, "Branch-specific commit", "branch.txt");
1936
1937 Command::new("git")
1939 .args(["checkout", "master"])
1940 .current_dir(&repo_path)
1941 .output()
1942 .unwrap();
1943
1944 let result = repo.delete_branch_unsafe("test-branch");
1947 let _ = result; }
1951
1952 #[test]
1953 fn test_force_push_unsafe() {
1954 let (_temp_dir, repo_path) = create_test_repo();
1955 let repo = GitRepository::open(&repo_path).unwrap();
1956
1957 create_commit(&repo_path, "Second commit", "test.txt");
1959 Command::new("git")
1960 .args(["checkout", "-b", "test-branch"])
1961 .current_dir(&repo_path)
1962 .output()
1963 .unwrap();
1964
1965 let _result = repo.force_push_branch_unsafe("test-branch", "test-branch");
1968 }
1970}