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 let is_ci = std::env::var("CI").is_ok();
1279 let no_confirm = std::env::var("CHECKOUT_NO_CONFIRM").is_ok();
1280 let is_non_interactive = is_ci || no_confirm;
1281
1282 if is_non_interactive {
1283 return Err(CascadeError::branch(
1284 format!(
1285 "Cannot checkout '{target}' with uncommitted changes in non-interactive mode. Commit your changes or use stash first."
1286 )
1287 ));
1288 }
1289
1290 println!("\n⚠️ CHECKOUT WARNING ⚠️");
1292 println!("You have uncommitted changes that could be lost:");
1293
1294 if !safety_info.modified_files.is_empty() {
1295 println!(
1296 "\n📝 Modified files ({}):",
1297 safety_info.modified_files.len()
1298 );
1299 for file in safety_info.modified_files.iter().take(10) {
1300 println!(" - {file}");
1301 }
1302 if safety_info.modified_files.len() > 10 {
1303 println!(" ... and {} more", safety_info.modified_files.len() - 10);
1304 }
1305 }
1306
1307 if !safety_info.staged_files.is_empty() {
1308 println!("\n📁 Staged files ({}):", safety_info.staged_files.len());
1309 for file in safety_info.staged_files.iter().take(10) {
1310 println!(" - {file}");
1311 }
1312 if safety_info.staged_files.len() > 10 {
1313 println!(" ... and {} more", safety_info.staged_files.len() - 10);
1314 }
1315 }
1316
1317 if !safety_info.untracked_files.is_empty() {
1318 println!(
1319 "\n❓ Untracked files ({}):",
1320 safety_info.untracked_files.len()
1321 );
1322 for file in safety_info.untracked_files.iter().take(5) {
1323 println!(" - {file}");
1324 }
1325 if safety_info.untracked_files.len() > 5 {
1326 println!(" ... and {} more", safety_info.untracked_files.len() - 5);
1327 }
1328 }
1329
1330 println!("\n🔄 Options:");
1331 println!("1. Stash changes and checkout (recommended)");
1332 println!("2. Force checkout (WILL LOSE UNCOMMITTED CHANGES)");
1333 println!("3. Cancel checkout");
1334
1335 let confirmation = Confirm::with_theme(&ColorfulTheme::default())
1336 .with_prompt("Would you like to stash your changes and proceed with checkout?")
1337 .interact()
1338 .map_err(|e| CascadeError::branch(format!("Could not get user confirmation: {e}")))?;
1339
1340 if confirmation {
1341 let stash_message = format!(
1343 "Auto-stash before checkout to {} at {}",
1344 target,
1345 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
1346 );
1347
1348 match self.create_stash(&stash_message) {
1349 Ok(stash_oid) => {
1350 println!("✅ Created stash: {stash_message} ({stash_oid})");
1351 println!("💡 You can restore with: git stash pop");
1352 }
1353 Err(e) => {
1354 println!("❌ Failed to create stash: {e}");
1355
1356 let force_confirm = Confirm::with_theme(&ColorfulTheme::default())
1357 .with_prompt("Stash failed. Force checkout anyway? (WILL LOSE CHANGES)")
1358 .interact()
1359 .map_err(|e| {
1360 CascadeError::branch(format!("Could not get confirmation: {e}"))
1361 })?;
1362
1363 if !force_confirm {
1364 return Err(CascadeError::branch(
1365 "Checkout cancelled by user".to_string(),
1366 ));
1367 }
1368 }
1369 }
1370 } else {
1371 return Err(CascadeError::branch(
1372 "Checkout cancelled by user".to_string(),
1373 ));
1374 }
1375
1376 Ok(())
1377 }
1378
1379 fn create_stash(&self, message: &str) -> Result<String> {
1381 warn!("Automatic stashing not yet implemented - please stash manually");
1385 Err(CascadeError::branch(format!(
1386 "Please manually stash your changes first: git stash push -m \"{message}\""
1387 )))
1388 }
1389
1390 fn get_modified_files(&self) -> Result<Vec<String>> {
1392 let mut opts = git2::StatusOptions::new();
1393 opts.include_untracked(false).include_ignored(false);
1394
1395 let statuses = self
1396 .repo
1397 .statuses(Some(&mut opts))
1398 .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
1399
1400 let mut modified_files = Vec::new();
1401 for status in statuses.iter() {
1402 let flags = status.status();
1403 if flags.contains(git2::Status::WT_MODIFIED) || flags.contains(git2::Status::WT_DELETED)
1404 {
1405 if let Some(path) = status.path() {
1406 modified_files.push(path.to_string());
1407 }
1408 }
1409 }
1410
1411 Ok(modified_files)
1412 }
1413
1414 fn get_staged_files(&self) -> Result<Vec<String>> {
1416 let mut opts = git2::StatusOptions::new();
1417 opts.include_untracked(false).include_ignored(false);
1418
1419 let statuses = self
1420 .repo
1421 .statuses(Some(&mut opts))
1422 .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
1423
1424 let mut staged_files = Vec::new();
1425 for status in statuses.iter() {
1426 let flags = status.status();
1427 if flags.contains(git2::Status::INDEX_MODIFIED)
1428 || flags.contains(git2::Status::INDEX_NEW)
1429 || flags.contains(git2::Status::INDEX_DELETED)
1430 {
1431 if let Some(path) = status.path() {
1432 staged_files.push(path.to_string());
1433 }
1434 }
1435 }
1436
1437 Ok(staged_files)
1438 }
1439
1440 fn count_commits_between(&self, from: &str, to: &str) -> Result<usize> {
1442 let commits = self.get_commits_between(from, to)?;
1443 Ok(commits.len())
1444 }
1445
1446 pub fn resolve_reference(&self, reference: &str) -> Result<git2::Commit<'_>> {
1448 if let Ok(oid) = Oid::from_str(reference) {
1450 if let Ok(commit) = self.repo.find_commit(oid) {
1451 return Ok(commit);
1452 }
1453 }
1454
1455 let obj = self.repo.revparse_single(reference).map_err(|e| {
1457 CascadeError::branch(format!("Could not resolve reference '{reference}': {e}"))
1458 })?;
1459
1460 obj.peel_to_commit().map_err(|e| {
1461 CascadeError::branch(format!(
1462 "Reference '{reference}' does not point to a commit: {e}"
1463 ))
1464 })
1465 }
1466
1467 pub fn reset_soft(&self, target_ref: &str) -> Result<()> {
1469 let target_commit = self.resolve_reference(target_ref)?;
1470
1471 self.repo
1472 .reset(target_commit.as_object(), git2::ResetType::Soft, None)
1473 .map_err(CascadeError::Git)?;
1474
1475 Ok(())
1476 }
1477
1478 pub fn find_branch_containing_commit(&self, commit_hash: &str) -> Result<String> {
1480 let oid = Oid::from_str(commit_hash).map_err(|e| {
1481 CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
1482 })?;
1483
1484 let branches = self
1486 .repo
1487 .branches(Some(git2::BranchType::Local))
1488 .map_err(CascadeError::Git)?;
1489
1490 for branch_result in branches {
1491 let (branch, _) = branch_result.map_err(CascadeError::Git)?;
1492
1493 if let Some(branch_name) = branch.name().map_err(CascadeError::Git)? {
1494 if let Ok(branch_head) = branch.get().peel_to_commit() {
1496 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
1498 revwalk.push(branch_head.id()).map_err(CascadeError::Git)?;
1499
1500 for commit_oid in revwalk {
1501 let commit_oid = commit_oid.map_err(CascadeError::Git)?;
1502 if commit_oid == oid {
1503 return Ok(branch_name.to_string());
1504 }
1505 }
1506 }
1507 }
1508 }
1509
1510 Err(CascadeError::branch(format!(
1512 "Commit {commit_hash} not found in any local branch"
1513 )))
1514 }
1515
1516 pub async fn fetch_async(&self) -> Result<()> {
1520 let repo_path = self.path.clone();
1521 crate::utils::async_ops::run_git_operation(move || {
1522 let repo = GitRepository::open(&repo_path)?;
1523 repo.fetch()
1524 })
1525 .await
1526 }
1527
1528 pub async fn pull_async(&self, branch: &str) -> Result<()> {
1530 let repo_path = self.path.clone();
1531 let branch_name = branch.to_string();
1532 crate::utils::async_ops::run_git_operation(move || {
1533 let repo = GitRepository::open(&repo_path)?;
1534 repo.pull(&branch_name)
1535 })
1536 .await
1537 }
1538
1539 pub async fn push_branch_async(&self, branch_name: &str) -> Result<()> {
1541 let repo_path = self.path.clone();
1542 let branch = branch_name.to_string();
1543 crate::utils::async_ops::run_git_operation(move || {
1544 let repo = GitRepository::open(&repo_path)?;
1545 repo.push(&branch)
1546 })
1547 .await
1548 }
1549
1550 pub async fn cherry_pick_commit_async(&self, commit_hash: &str) -> Result<String> {
1552 let repo_path = self.path.clone();
1553 let hash = commit_hash.to_string();
1554 crate::utils::async_ops::run_git_operation(move || {
1555 let repo = GitRepository::open(&repo_path)?;
1556 repo.cherry_pick(&hash)
1557 })
1558 .await
1559 }
1560
1561 pub async fn get_commit_hashes_between_async(
1563 &self,
1564 from: &str,
1565 to: &str,
1566 ) -> Result<Vec<String>> {
1567 let repo_path = self.path.clone();
1568 let from_str = from.to_string();
1569 let to_str = to.to_string();
1570 crate::utils::async_ops::run_git_operation(move || {
1571 let repo = GitRepository::open(&repo_path)?;
1572 let commits = repo.get_commits_between(&from_str, &to_str)?;
1573 Ok(commits.into_iter().map(|c| c.id().to_string()).collect())
1574 })
1575 .await
1576 }
1577}
1578
1579#[cfg(test)]
1580mod tests {
1581 use super::*;
1582 use std::process::Command;
1583 use tempfile::TempDir;
1584
1585 fn create_test_repo() -> (TempDir, PathBuf) {
1586 let temp_dir = TempDir::new().unwrap();
1587 let repo_path = temp_dir.path().to_path_buf();
1588
1589 Command::new("git")
1591 .args(["init"])
1592 .current_dir(&repo_path)
1593 .output()
1594 .unwrap();
1595 Command::new("git")
1596 .args(["config", "user.name", "Test"])
1597 .current_dir(&repo_path)
1598 .output()
1599 .unwrap();
1600 Command::new("git")
1601 .args(["config", "user.email", "test@test.com"])
1602 .current_dir(&repo_path)
1603 .output()
1604 .unwrap();
1605
1606 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
1608 Command::new("git")
1609 .args(["add", "."])
1610 .current_dir(&repo_path)
1611 .output()
1612 .unwrap();
1613 Command::new("git")
1614 .args(["commit", "-m", "Initial commit"])
1615 .current_dir(&repo_path)
1616 .output()
1617 .unwrap();
1618
1619 (temp_dir, repo_path)
1620 }
1621
1622 fn create_commit(repo_path: &PathBuf, message: &str, filename: &str) {
1623 let file_path = repo_path.join(filename);
1624 std::fs::write(&file_path, format!("Content for {filename}\n")).unwrap();
1625
1626 Command::new("git")
1627 .args(["add", filename])
1628 .current_dir(repo_path)
1629 .output()
1630 .unwrap();
1631 Command::new("git")
1632 .args(["commit", "-m", message])
1633 .current_dir(repo_path)
1634 .output()
1635 .unwrap();
1636 }
1637
1638 #[test]
1639 fn test_repository_info() {
1640 let (_temp_dir, repo_path) = create_test_repo();
1641 let repo = GitRepository::open(&repo_path).unwrap();
1642
1643 let info = repo.get_info().unwrap();
1644 assert!(!info.is_dirty); assert!(
1646 info.head_branch == Some("master".to_string())
1647 || info.head_branch == Some("main".to_string()),
1648 "Expected default branch to be 'master' or 'main', got {:?}",
1649 info.head_branch
1650 );
1651 assert!(info.head_commit.is_some()); assert!(info.untracked_files.is_empty()); }
1654
1655 #[test]
1656 fn test_force_push_branch_basic() {
1657 let (_temp_dir, repo_path) = create_test_repo();
1658 let repo = GitRepository::open(&repo_path).unwrap();
1659
1660 let default_branch = repo.get_current_branch().unwrap();
1662
1663 create_commit(&repo_path, "Feature commit 1", "feature1.rs");
1665 Command::new("git")
1666 .args(["checkout", "-b", "source-branch"])
1667 .current_dir(&repo_path)
1668 .output()
1669 .unwrap();
1670 create_commit(&repo_path, "Feature commit 2", "feature2.rs");
1671
1672 Command::new("git")
1674 .args(["checkout", &default_branch])
1675 .current_dir(&repo_path)
1676 .output()
1677 .unwrap();
1678 Command::new("git")
1679 .args(["checkout", "-b", "target-branch"])
1680 .current_dir(&repo_path)
1681 .output()
1682 .unwrap();
1683 create_commit(&repo_path, "Target commit", "target.rs");
1684
1685 let result = repo.force_push_branch("target-branch", "source-branch");
1687
1688 assert!(result.is_ok() || result.is_err()); }
1692
1693 #[test]
1694 fn test_force_push_branch_nonexistent_branches() {
1695 let (_temp_dir, repo_path) = create_test_repo();
1696 let repo = GitRepository::open(&repo_path).unwrap();
1697
1698 let default_branch = repo.get_current_branch().unwrap();
1700
1701 let result = repo.force_push_branch("target", "nonexistent-source");
1703 assert!(result.is_err());
1704
1705 let result = repo.force_push_branch("nonexistent-target", &default_branch);
1707 assert!(result.is_err());
1708 }
1709
1710 #[test]
1711 fn test_force_push_workflow_simulation() {
1712 let (_temp_dir, repo_path) = create_test_repo();
1713 let repo = GitRepository::open(&repo_path).unwrap();
1714
1715 Command::new("git")
1718 .args(["checkout", "-b", "feature-auth"])
1719 .current_dir(&repo_path)
1720 .output()
1721 .unwrap();
1722 create_commit(&repo_path, "Add authentication", "auth.rs");
1723
1724 Command::new("git")
1726 .args(["checkout", "-b", "feature-auth-v2"])
1727 .current_dir(&repo_path)
1728 .output()
1729 .unwrap();
1730 create_commit(&repo_path, "Fix auth validation", "auth.rs");
1731
1732 let result = repo.force_push_branch("feature-auth", "feature-auth-v2");
1734
1735 match result {
1737 Ok(_) => {
1738 Command::new("git")
1740 .args(["checkout", "feature-auth"])
1741 .current_dir(&repo_path)
1742 .output()
1743 .unwrap();
1744 let log_output = Command::new("git")
1745 .args(["log", "--oneline", "-2"])
1746 .current_dir(&repo_path)
1747 .output()
1748 .unwrap();
1749 let log_str = String::from_utf8_lossy(&log_output.stdout);
1750 assert!(
1751 log_str.contains("Fix auth validation")
1752 || log_str.contains("Add authentication")
1753 );
1754 }
1755 Err(_) => {
1756 }
1759 }
1760 }
1761
1762 #[test]
1763 fn test_branch_operations() {
1764 let (_temp_dir, repo_path) = create_test_repo();
1765 let repo = GitRepository::open(&repo_path).unwrap();
1766
1767 let current = repo.get_current_branch().unwrap();
1769 assert!(
1770 current == "master" || current == "main",
1771 "Expected default branch to be 'master' or 'main', got '{current}'"
1772 );
1773
1774 Command::new("git")
1776 .args(["checkout", "-b", "test-branch"])
1777 .current_dir(&repo_path)
1778 .output()
1779 .unwrap();
1780 let current = repo.get_current_branch().unwrap();
1781 assert_eq!(current, "test-branch");
1782 }
1783
1784 #[test]
1785 fn test_commit_operations() {
1786 let (_temp_dir, repo_path) = create_test_repo();
1787 let repo = GitRepository::open(&repo_path).unwrap();
1788
1789 let head = repo.get_head_commit().unwrap();
1791 assert_eq!(head.message().unwrap().trim(), "Initial commit");
1792
1793 let hash = head.id().to_string();
1795 let same_commit = repo.get_commit(&hash).unwrap();
1796 assert_eq!(head.id(), same_commit.id());
1797 }
1798
1799 #[test]
1800 fn test_checkout_safety_clean_repo() {
1801 let (_temp_dir, repo_path) = create_test_repo();
1802 let repo = GitRepository::open(&repo_path).unwrap();
1803
1804 create_commit(&repo_path, "Second commit", "test.txt");
1806 Command::new("git")
1807 .args(["checkout", "-b", "test-branch"])
1808 .current_dir(&repo_path)
1809 .output()
1810 .unwrap();
1811
1812 let safety_result = repo.check_checkout_safety("main");
1814 assert!(safety_result.is_ok());
1815 assert!(safety_result.unwrap().is_none()); }
1817
1818 #[test]
1819 fn test_checkout_safety_with_modified_files() {
1820 let (_temp_dir, repo_path) = create_test_repo();
1821 let repo = GitRepository::open(&repo_path).unwrap();
1822
1823 Command::new("git")
1825 .args(["checkout", "-b", "test-branch"])
1826 .current_dir(&repo_path)
1827 .output()
1828 .unwrap();
1829
1830 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
1832
1833 let safety_result = repo.check_checkout_safety("main");
1835 assert!(safety_result.is_ok());
1836 let safety_info = safety_result.unwrap();
1837 assert!(safety_info.is_some());
1838
1839 let info = safety_info.unwrap();
1840 assert!(!info.modified_files.is_empty());
1841 assert!(info.modified_files.contains(&"README.md".to_string()));
1842 }
1843
1844 #[test]
1845 fn test_unsafe_checkout_methods() {
1846 let (_temp_dir, repo_path) = create_test_repo();
1847 let repo = GitRepository::open(&repo_path).unwrap();
1848
1849 create_commit(&repo_path, "Second commit", "test.txt");
1851 Command::new("git")
1852 .args(["checkout", "-b", "test-branch"])
1853 .current_dir(&repo_path)
1854 .output()
1855 .unwrap();
1856
1857 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
1859
1860 let _result = repo.checkout_branch_unsafe("master");
1862 let head_commit = repo.get_head_commit().unwrap();
1867 let commit_hash = head_commit.id().to_string();
1868 let _result = repo.checkout_commit_unsafe(&commit_hash);
1869 }
1871
1872 #[test]
1873 fn test_get_modified_files() {
1874 let (_temp_dir, repo_path) = create_test_repo();
1875 let repo = GitRepository::open(&repo_path).unwrap();
1876
1877 let modified = repo.get_modified_files().unwrap();
1879 assert!(modified.is_empty());
1880
1881 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
1883
1884 let modified = repo.get_modified_files().unwrap();
1886 assert_eq!(modified.len(), 1);
1887 assert!(modified.contains(&"README.md".to_string()));
1888 }
1889
1890 #[test]
1891 fn test_get_staged_files() {
1892 let (_temp_dir, repo_path) = create_test_repo();
1893 let repo = GitRepository::open(&repo_path).unwrap();
1894
1895 let staged = repo.get_staged_files().unwrap();
1897 assert!(staged.is_empty());
1898
1899 std::fs::write(repo_path.join("staged.txt"), "Staged content").unwrap();
1901 Command::new("git")
1902 .args(["add", "staged.txt"])
1903 .current_dir(&repo_path)
1904 .output()
1905 .unwrap();
1906
1907 let staged = repo.get_staged_files().unwrap();
1909 assert_eq!(staged.len(), 1);
1910 assert!(staged.contains(&"staged.txt".to_string()));
1911 }
1912
1913 #[test]
1914 fn test_create_stash_fallback() {
1915 let (_temp_dir, repo_path) = create_test_repo();
1916 let repo = GitRepository::open(&repo_path).unwrap();
1917
1918 let result = repo.create_stash("test stash");
1920 assert!(result.is_err());
1921 let error_msg = result.unwrap_err().to_string();
1922 assert!(error_msg.contains("git stash push"));
1923 }
1924
1925 #[test]
1926 fn test_delete_branch_unsafe() {
1927 let (_temp_dir, repo_path) = create_test_repo();
1928 let repo = GitRepository::open(&repo_path).unwrap();
1929
1930 create_commit(&repo_path, "Second commit", "test.txt");
1932 Command::new("git")
1933 .args(["checkout", "-b", "test-branch"])
1934 .current_dir(&repo_path)
1935 .output()
1936 .unwrap();
1937
1938 create_commit(&repo_path, "Branch-specific commit", "branch.txt");
1940
1941 Command::new("git")
1943 .args(["checkout", "master"])
1944 .current_dir(&repo_path)
1945 .output()
1946 .unwrap();
1947
1948 let result = repo.delete_branch_unsafe("test-branch");
1951 let _ = result; }
1955
1956 #[test]
1957 fn test_force_push_unsafe() {
1958 let (_temp_dir, repo_path) = create_test_repo();
1959 let repo = GitRepository::open(&repo_path).unwrap();
1960
1961 create_commit(&repo_path, "Second commit", "test.txt");
1963 Command::new("git")
1964 .args(["checkout", "-b", "test-branch"])
1965 .current_dir(&repo_path)
1966 .output()
1967 .unwrap();
1968
1969 let _result = repo.force_push_branch_unsafe("test-branch", "test-branch");
1972 }
1974}