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 branch_exists_or_fetch(&self, name: &str) -> Result<bool> {
302 if self.repo.find_branch(name, git2::BranchType::Local).is_ok() {
304 return Ok(true);
305 }
306
307 println!("🔍 Branch '{name}' not found locally, trying to fetch from remote...");
309
310 use std::process::Command;
311
312 let fetch_result = Command::new("git")
314 .args(["fetch", "origin", &format!("{name}:{name}")])
315 .current_dir(&self.path)
316 .output();
317
318 match fetch_result {
319 Ok(output) => {
320 if output.status.success() {
321 println!("✅ Successfully fetched '{name}' from origin");
322 return Ok(self.repo.find_branch(name, git2::BranchType::Local).is_ok());
324 } else {
325 let stderr = String::from_utf8_lossy(&output.stderr);
326 tracing::debug!("Failed to fetch branch '{name}': {stderr}");
327 }
328 }
329 Err(e) => {
330 tracing::debug!("Git fetch command failed: {e}");
331 }
332 }
333
334 if name.contains('/') {
336 println!("🔍 Trying alternative fetch patterns...");
337
338 let fetch_all_result = Command::new("git")
340 .args(["fetch", "origin"])
341 .current_dir(&self.path)
342 .output();
343
344 if let Ok(output) = fetch_all_result {
345 if output.status.success() {
346 let checkout_result = Command::new("git")
348 .args(["checkout", "-b", name, &format!("origin/{name}")])
349 .current_dir(&self.path)
350 .output();
351
352 if let Ok(checkout_output) = checkout_result {
353 if checkout_output.status.success() {
354 println!(
355 "✅ Successfully created local branch '{name}' from origin/{name}"
356 );
357 return Ok(true);
358 }
359 }
360 }
361 }
362 }
363
364 Ok(false)
366 }
367
368 pub fn get_branch_commit_hash(&self, branch_name: &str) -> Result<String> {
370 let branch = self
371 .repo
372 .find_branch(branch_name, git2::BranchType::Local)
373 .map_err(|e| {
374 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
375 })?;
376
377 let commit = branch.get().peel_to_commit().map_err(|e| {
378 CascadeError::branch(format!(
379 "Could not get commit for branch '{branch_name}': {e}"
380 ))
381 })?;
382
383 Ok(commit.id().to_string())
384 }
385
386 pub fn list_branches(&self) -> Result<Vec<String>> {
388 let branches = self
389 .repo
390 .branches(Some(git2::BranchType::Local))
391 .map_err(CascadeError::Git)?;
392
393 let mut branch_names = Vec::new();
394 for branch in branches {
395 let (branch, _) = branch.map_err(CascadeError::Git)?;
396 if let Some(name) = branch.name().map_err(CascadeError::Git)? {
397 branch_names.push(name.to_string());
398 }
399 }
400
401 Ok(branch_names)
402 }
403
404 pub fn commit(&self, message: &str) -> Result<String> {
406 let signature = self.get_signature()?;
407 let tree_id = self.get_index_tree()?;
408 let tree = self.repo.find_tree(tree_id).map_err(CascadeError::Git)?;
409
410 let head = self.repo.head().map_err(CascadeError::Git)?;
412 let parent_commit = head.peel_to_commit().map_err(CascadeError::Git)?;
413
414 let commit_id = self
415 .repo
416 .commit(
417 Some("HEAD"),
418 &signature,
419 &signature,
420 message,
421 &tree,
422 &[&parent_commit],
423 )
424 .map_err(CascadeError::Git)?;
425
426 tracing::info!("Created commit: {} - {}", commit_id, message);
427 Ok(commit_id.to_string())
428 }
429
430 pub fn stage_all(&self) -> Result<()> {
432 let mut index = self.repo.index().map_err(CascadeError::Git)?;
433
434 index
435 .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
436 .map_err(CascadeError::Git)?;
437
438 index.write().map_err(CascadeError::Git)?;
439
440 tracing::debug!("Staged all changes");
441 Ok(())
442 }
443
444 pub fn path(&self) -> &Path {
446 &self.path
447 }
448
449 pub fn commit_exists(&self, commit_hash: &str) -> Result<bool> {
451 match Oid::from_str(commit_hash) {
452 Ok(oid) => match self.repo.find_commit(oid) {
453 Ok(_) => Ok(true),
454 Err(_) => Ok(false),
455 },
456 Err(_) => Ok(false),
457 }
458 }
459
460 pub fn get_head_commit(&self) -> Result<git2::Commit<'_>> {
462 let head = self
463 .repo
464 .head()
465 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
466 head.peel_to_commit()
467 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))
468 }
469
470 pub fn get_commit(&self, commit_hash: &str) -> Result<git2::Commit<'_>> {
472 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
473
474 self.repo.find_commit(oid).map_err(CascadeError::Git)
475 }
476
477 pub fn get_branch_head(&self, branch_name: &str) -> Result<String> {
479 let branch = self
480 .repo
481 .find_branch(branch_name, git2::BranchType::Local)
482 .map_err(|e| {
483 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
484 })?;
485
486 let commit = branch.get().peel_to_commit().map_err(|e| {
487 CascadeError::branch(format!(
488 "Could not get commit for branch '{branch_name}': {e}"
489 ))
490 })?;
491
492 Ok(commit.id().to_string())
493 }
494
495 fn get_signature(&self) -> Result<Signature<'_>> {
497 if let Ok(config) = self.repo.config() {
499 if let (Ok(name), Ok(email)) = (
500 config.get_string("user.name"),
501 config.get_string("user.email"),
502 ) {
503 return Signature::now(&name, &email).map_err(CascadeError::Git);
504 }
505 }
506
507 Signature::now("Cascade CLI", "cascade@example.com").map_err(CascadeError::Git)
509 }
510
511 fn get_index_tree(&self) -> Result<Oid> {
513 let mut index = self.repo.index().map_err(CascadeError::Git)?;
514
515 index.write_tree().map_err(CascadeError::Git)
516 }
517
518 pub fn get_status(&self) -> Result<git2::Statuses<'_>> {
520 self.repo.statuses(None).map_err(CascadeError::Git)
521 }
522
523 pub fn get_remote_url(&self, name: &str) -> Result<String> {
525 let remote = self.repo.find_remote(name).map_err(CascadeError::Git)?;
526
527 let url = remote.url().ok_or_else(|| {
528 CascadeError::Git(git2::Error::from_str("Remote URL is not valid UTF-8"))
529 })?;
530
531 Ok(url.to_string())
532 }
533
534 pub fn cherry_pick(&self, commit_hash: &str) -> Result<String> {
536 tracing::debug!("Cherry-picking commit {}", commit_hash);
537
538 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
539 let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
540
541 let commit_tree = commit.tree().map_err(CascadeError::Git)?;
543
544 let parent_commit = if commit.parent_count() > 0 {
546 commit.parent(0).map_err(CascadeError::Git)?
547 } else {
548 let empty_tree_oid = self.repo.treebuilder(None)?.write()?;
550 let empty_tree = self.repo.find_tree(empty_tree_oid)?;
551 let sig = self.get_signature()?;
552 return self
553 .repo
554 .commit(
555 Some("HEAD"),
556 &sig,
557 &sig,
558 commit.message().unwrap_or("Cherry-picked commit"),
559 &empty_tree,
560 &[],
561 )
562 .map(|oid| oid.to_string())
563 .map_err(CascadeError::Git);
564 };
565
566 let parent_tree = parent_commit.tree().map_err(CascadeError::Git)?;
567
568 let head_commit = self.get_head_commit()?;
570 let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
571
572 let mut index = self
574 .repo
575 .merge_trees(&parent_tree, &head_tree, &commit_tree, None)
576 .map_err(CascadeError::Git)?;
577
578 if index.has_conflicts() {
580 return Err(CascadeError::branch(format!(
581 "Cherry-pick of {commit_hash} has conflicts that need manual resolution"
582 )));
583 }
584
585 let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
587 let merged_tree = self
588 .repo
589 .find_tree(merged_tree_oid)
590 .map_err(CascadeError::Git)?;
591
592 let signature = self.get_signature()?;
594 let message = format!("Cherry-pick: {}", commit.message().unwrap_or(""));
595
596 let new_commit_oid = self
597 .repo
598 .commit(
599 Some("HEAD"),
600 &signature,
601 &signature,
602 &message,
603 &merged_tree,
604 &[&head_commit],
605 )
606 .map_err(CascadeError::Git)?;
607
608 tracing::info!("Cherry-picked {} -> {}", commit_hash, new_commit_oid);
609 Ok(new_commit_oid.to_string())
610 }
611
612 pub fn has_conflicts(&self) -> Result<bool> {
614 let index = self.repo.index().map_err(CascadeError::Git)?;
615 Ok(index.has_conflicts())
616 }
617
618 pub fn get_conflicted_files(&self) -> Result<Vec<String>> {
620 let index = self.repo.index().map_err(CascadeError::Git)?;
621
622 let mut conflicts = Vec::new();
623
624 let conflict_iter = index.conflicts().map_err(CascadeError::Git)?;
626
627 for conflict in conflict_iter {
628 let conflict = conflict.map_err(CascadeError::Git)?;
629 if let Some(our) = conflict.our {
630 if let Ok(path) = std::str::from_utf8(&our.path) {
631 conflicts.push(path.to_string());
632 }
633 } else if let Some(their) = conflict.their {
634 if let Ok(path) = std::str::from_utf8(&their.path) {
635 conflicts.push(path.to_string());
636 }
637 }
638 }
639
640 Ok(conflicts)
641 }
642
643 pub fn fetch(&self) -> Result<()> {
645 tracing::info!("Fetching from origin");
646
647 let mut remote = self
648 .repo
649 .find_remote("origin")
650 .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
651
652 let mut callbacks = git2::RemoteCallbacks::new();
654
655 callbacks.credentials(|_url, username_from_url, _allowed_types| {
657 if let Some(username) = username_from_url {
658 git2::Cred::ssh_key_from_agent(username)
660 } else {
661 git2::Cred::default()
663 }
664 });
665
666 let mut fetch_options = git2::FetchOptions::new();
668 fetch_options.remote_callbacks(callbacks);
669
670 remote
672 .fetch::<&str>(&[], Some(&mut fetch_options), None)
673 .map_err(CascadeError::Git)?;
674
675 tracing::debug!("Fetch completed successfully");
676 Ok(())
677 }
678
679 pub fn pull(&self, branch: &str) -> Result<()> {
681 tracing::info!("Pulling branch: {}", branch);
682
683 self.fetch()?;
685
686 let remote_branch_name = format!("origin/{branch}");
688 let remote_oid = self
689 .repo
690 .refname_to_id(&format!("refs/remotes/{remote_branch_name}"))
691 .map_err(|e| {
692 CascadeError::branch(format!("Remote branch {remote_branch_name} not found: {e}"))
693 })?;
694
695 let remote_commit = self
696 .repo
697 .find_commit(remote_oid)
698 .map_err(CascadeError::Git)?;
699
700 let head_commit = self.get_head_commit()?;
702
703 if head_commit.id() == remote_commit.id() {
705 tracing::debug!("Already up to date");
706 return Ok(());
707 }
708
709 let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
711 let remote_tree = remote_commit.tree().map_err(CascadeError::Git)?;
712
713 let merge_base_oid = self
715 .repo
716 .merge_base(head_commit.id(), remote_commit.id())
717 .map_err(CascadeError::Git)?;
718 let merge_base_commit = self
719 .repo
720 .find_commit(merge_base_oid)
721 .map_err(CascadeError::Git)?;
722 let merge_base_tree = merge_base_commit.tree().map_err(CascadeError::Git)?;
723
724 let mut index = self
726 .repo
727 .merge_trees(&merge_base_tree, &head_tree, &remote_tree, None)
728 .map_err(CascadeError::Git)?;
729
730 if index.has_conflicts() {
731 return Err(CascadeError::branch(
732 "Pull has conflicts that need manual resolution".to_string(),
733 ));
734 }
735
736 let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
738 let merged_tree = self
739 .repo
740 .find_tree(merged_tree_oid)
741 .map_err(CascadeError::Git)?;
742
743 let signature = self.get_signature()?;
744 let message = format!("Merge branch '{branch}' from origin");
745
746 self.repo
747 .commit(
748 Some("HEAD"),
749 &signature,
750 &signature,
751 &message,
752 &merged_tree,
753 &[&head_commit, &remote_commit],
754 )
755 .map_err(CascadeError::Git)?;
756
757 tracing::info!("Pull completed successfully");
758 Ok(())
759 }
760
761 pub fn push(&self, branch: &str) -> Result<()> {
763 tracing::info!("Pushing branch: {}", branch);
764
765 let mut remote = self
766 .repo
767 .find_remote("origin")
768 .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
769
770 let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
771
772 let mut callbacks = git2::RemoteCallbacks::new();
774
775 callbacks.credentials(|_url, username_from_url, _allowed_types| {
777 if let Some(username) = username_from_url {
778 git2::Cred::ssh_key_from_agent(username)
780 } else {
781 git2::Cred::default()
783 }
784 });
785
786 let mut push_options = git2::PushOptions::new();
788 push_options.remote_callbacks(callbacks);
789
790 remote
791 .push(&[&refspec], Some(&mut push_options))
792 .map_err(CascadeError::Git)?;
793
794 tracing::info!("Push completed successfully");
795 Ok(())
796 }
797
798 pub fn delete_branch(&self, name: &str) -> Result<()> {
800 self.delete_branch_with_options(name, false)
801 }
802
803 pub fn delete_branch_unsafe(&self, name: &str) -> Result<()> {
805 self.delete_branch_with_options(name, true)
806 }
807
808 fn delete_branch_with_options(&self, name: &str, force_unsafe: bool) -> Result<()> {
810 info!("Attempting to delete branch: {}", name);
811
812 if !force_unsafe {
814 let safety_result = self.check_branch_deletion_safety(name)?;
815 if let Some(safety_info) = safety_result {
816 self.handle_branch_deletion_confirmation(name, &safety_info)?;
818 }
819 }
820
821 let mut branch = self
822 .repo
823 .find_branch(name, git2::BranchType::Local)
824 .map_err(|e| CascadeError::branch(format!("Could not find branch '{name}': {e}")))?;
825
826 branch
827 .delete()
828 .map_err(|e| CascadeError::branch(format!("Could not delete branch '{name}': {e}")))?;
829
830 info!("Successfully deleted branch '{}'", name);
831 Ok(())
832 }
833
834 pub fn get_commits_between(&self, from: &str, to: &str) -> Result<Vec<git2::Commit<'_>>> {
836 let from_oid = self
837 .repo
838 .refname_to_id(&format!("refs/heads/{from}"))
839 .or_else(|_| Oid::from_str(from))
840 .map_err(|e| CascadeError::branch(format!("Invalid from reference '{from}': {e}")))?;
841
842 let to_oid = self
843 .repo
844 .refname_to_id(&format!("refs/heads/{to}"))
845 .or_else(|_| Oid::from_str(to))
846 .map_err(|e| CascadeError::branch(format!("Invalid to reference '{to}': {e}")))?;
847
848 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
849
850 revwalk.push(to_oid).map_err(CascadeError::Git)?;
851 revwalk.hide(from_oid).map_err(CascadeError::Git)?;
852
853 let mut commits = Vec::new();
854 for oid in revwalk {
855 let oid = oid.map_err(CascadeError::Git)?;
856 let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
857 commits.push(commit);
858 }
859
860 Ok(commits)
861 }
862
863 pub fn force_push_branch(&self, target_branch: &str, source_branch: &str) -> Result<()> {
866 self.force_push_branch_with_options(target_branch, source_branch, false)
867 }
868
869 pub fn force_push_branch_unsafe(&self, target_branch: &str, source_branch: &str) -> Result<()> {
871 self.force_push_branch_with_options(target_branch, source_branch, true)
872 }
873
874 fn force_push_branch_with_options(
876 &self,
877 target_branch: &str,
878 source_branch: &str,
879 force_unsafe: bool,
880 ) -> Result<()> {
881 info!(
882 "Force pushing {} content to {} to preserve PR history",
883 source_branch, target_branch
884 );
885
886 if !force_unsafe {
888 let safety_result = self.check_force_push_safety_enhanced(target_branch)?;
889 if let Some(backup_info) = safety_result {
890 self.create_backup_branch(target_branch, &backup_info.remote_commit_id)?;
892 info!(
893 "✅ Created backup branch: {}",
894 backup_info.backup_branch_name
895 );
896 }
897 }
898
899 let source_ref = self
901 .repo
902 .find_reference(&format!("refs/heads/{source_branch}"))
903 .map_err(|e| {
904 CascadeError::config(format!("Failed to find source branch {source_branch}: {e}"))
905 })?;
906 let source_commit = source_ref.peel_to_commit().map_err(|e| {
907 CascadeError::config(format!(
908 "Failed to get commit for source branch {source_branch}: {e}"
909 ))
910 })?;
911
912 let mut target_ref = self
914 .repo
915 .find_reference(&format!("refs/heads/{target_branch}"))
916 .map_err(|e| {
917 CascadeError::config(format!("Failed to find target branch {target_branch}: {e}"))
918 })?;
919
920 target_ref
921 .set_target(source_commit.id(), "Force push from rebase")
922 .map_err(|e| {
923 CascadeError::config(format!(
924 "Failed to update target branch {target_branch}: {e}"
925 ))
926 })?;
927
928 let mut remote = self
930 .repo
931 .find_remote("origin")
932 .map_err(|e| CascadeError::config(format!("Failed to find origin remote: {e}")))?;
933
934 let refspec = format!("+refs/heads/{target_branch}:refs/heads/{target_branch}");
935
936 let mut callbacks = git2::RemoteCallbacks::new();
938
939 callbacks.credentials(|_url, username_from_url, _allowed_types| {
941 if let Some(username) = username_from_url {
942 git2::Cred::ssh_key_from_agent(username)
944 } else {
945 git2::Cred::default()
947 }
948 });
949
950 let mut push_options = git2::PushOptions::new();
952 push_options.remote_callbacks(callbacks);
953
954 remote
955 .push(&[&refspec], Some(&mut push_options))
956 .map_err(|e| {
957 CascadeError::config(format!("Failed to force push {target_branch}: {e}"))
958 })?;
959
960 info!(
961 "✅ Successfully force pushed {} to preserve PR history",
962 target_branch
963 );
964 Ok(())
965 }
966
967 fn check_force_push_safety_enhanced(
970 &self,
971 target_branch: &str,
972 ) -> Result<Option<ForceBackupInfo>> {
973 match self.fetch() {
975 Ok(_) => {}
976 Err(e) => {
977 warn!("Could not fetch latest changes for safety check: {}", e);
979 }
980 }
981
982 let remote_ref = format!("refs/remotes/origin/{target_branch}");
984 let local_ref = format!("refs/heads/{target_branch}");
985
986 let local_commit = match self.repo.find_reference(&local_ref) {
988 Ok(reference) => reference.peel_to_commit().ok(),
989 Err(_) => None,
990 };
991
992 let remote_commit = match self.repo.find_reference(&remote_ref) {
993 Ok(reference) => reference.peel_to_commit().ok(),
994 Err(_) => None,
995 };
996
997 if let (Some(local), Some(remote)) = (local_commit, remote_commit) {
999 if local.id() != remote.id() {
1000 let merge_base_oid = self
1002 .repo
1003 .merge_base(local.id(), remote.id())
1004 .map_err(|e| CascadeError::config(format!("Failed to find merge base: {e}")))?;
1005
1006 if merge_base_oid != remote.id() {
1008 let commits_to_lose = self.count_commits_between(
1009 &merge_base_oid.to_string(),
1010 &remote.id().to_string(),
1011 )?;
1012
1013 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1015 let backup_branch_name = format!("{target_branch}_backup_{timestamp}");
1016
1017 warn!(
1018 "⚠️ Force push to '{}' would overwrite {} commits on remote",
1019 target_branch, commits_to_lose
1020 );
1021
1022 if std::env::var("CI").is_ok() || std::env::var("FORCE_PUSH_NO_CONFIRM").is_ok()
1024 {
1025 info!(
1026 "Non-interactive environment detected, proceeding with backup creation"
1027 );
1028 return Ok(Some(ForceBackupInfo {
1029 backup_branch_name,
1030 remote_commit_id: remote.id().to_string(),
1031 commits_that_would_be_lost: commits_to_lose,
1032 }));
1033 }
1034
1035 println!("\n⚠️ FORCE PUSH WARNING ⚠️");
1037 println!("Force push to '{target_branch}' would overwrite {commits_to_lose} commits on remote:");
1038
1039 match self
1041 .get_commits_between(&merge_base_oid.to_string(), &remote.id().to_string())
1042 {
1043 Ok(commits) => {
1044 println!("\nCommits that would be lost:");
1045 for (i, commit) in commits.iter().take(5).enumerate() {
1046 let short_hash = &commit.id().to_string()[..8];
1047 let summary = commit.summary().unwrap_or("<no message>");
1048 println!(" {}. {} - {}", i + 1, short_hash, summary);
1049 }
1050 if commits.len() > 5 {
1051 println!(" ... and {} more commits", commits.len() - 5);
1052 }
1053 }
1054 Err(_) => {
1055 println!(" (Unable to retrieve commit details)");
1056 }
1057 }
1058
1059 println!("\nA backup branch '{backup_branch_name}' will be created before proceeding.");
1060
1061 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
1062 .with_prompt("Do you want to proceed with the force push?")
1063 .default(false)
1064 .interact()
1065 .map_err(|e| {
1066 CascadeError::config(format!("Failed to get user confirmation: {e}"))
1067 })?;
1068
1069 if !confirmed {
1070 return Err(CascadeError::config(
1071 "Force push cancelled by user. Use --force to bypass this check."
1072 .to_string(),
1073 ));
1074 }
1075
1076 return Ok(Some(ForceBackupInfo {
1077 backup_branch_name,
1078 remote_commit_id: remote.id().to_string(),
1079 commits_that_would_be_lost: commits_to_lose,
1080 }));
1081 }
1082 }
1083 }
1084
1085 Ok(None)
1086 }
1087
1088 fn create_backup_branch(&self, original_branch: &str, remote_commit_id: &str) -> Result<()> {
1090 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1091 let backup_branch_name = format!("{original_branch}_backup_{timestamp}");
1092
1093 let commit_oid = Oid::from_str(remote_commit_id).map_err(|e| {
1095 CascadeError::config(format!("Invalid commit ID {remote_commit_id}: {e}"))
1096 })?;
1097
1098 let commit = self.repo.find_commit(commit_oid).map_err(|e| {
1100 CascadeError::config(format!("Failed to find commit {remote_commit_id}: {e}"))
1101 })?;
1102
1103 self.repo
1105 .branch(&backup_branch_name, &commit, false)
1106 .map_err(|e| {
1107 CascadeError::config(format!(
1108 "Failed to create backup branch {backup_branch_name}: {e}"
1109 ))
1110 })?;
1111
1112 info!(
1113 "✅ Created backup branch '{}' pointing to {}",
1114 backup_branch_name,
1115 &remote_commit_id[..8]
1116 );
1117 Ok(())
1118 }
1119
1120 fn check_branch_deletion_safety(
1123 &self,
1124 branch_name: &str,
1125 ) -> Result<Option<BranchDeletionSafety>> {
1126 match self.fetch() {
1128 Ok(_) => {}
1129 Err(e) => {
1130 warn!(
1131 "Could not fetch latest changes for branch deletion safety check: {}",
1132 e
1133 );
1134 }
1135 }
1136
1137 let branch = self
1139 .repo
1140 .find_branch(branch_name, git2::BranchType::Local)
1141 .map_err(|e| {
1142 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
1143 })?;
1144
1145 let _branch_commit = branch.get().peel_to_commit().map_err(|e| {
1146 CascadeError::branch(format!(
1147 "Could not get commit for branch '{branch_name}': {e}"
1148 ))
1149 })?;
1150
1151 let main_branch_name = self.detect_main_branch()?;
1153
1154 let is_merged_to_main = self.is_branch_merged_to_main(branch_name, &main_branch_name)?;
1156
1157 let remote_tracking_branch = self.get_remote_tracking_branch(branch_name);
1159
1160 let mut unpushed_commits = Vec::new();
1161
1162 if let Some(ref remote_branch) = remote_tracking_branch {
1164 match self.get_commits_between(remote_branch, branch_name) {
1165 Ok(commits) => {
1166 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1167 }
1168 Err(_) => {
1169 if !is_merged_to_main {
1171 if let Ok(commits) =
1172 self.get_commits_between(&main_branch_name, branch_name)
1173 {
1174 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1175 }
1176 }
1177 }
1178 }
1179 } else if !is_merged_to_main {
1180 if let Ok(commits) = self.get_commits_between(&main_branch_name, branch_name) {
1182 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1183 }
1184 }
1185
1186 if !unpushed_commits.is_empty() || (!is_merged_to_main && remote_tracking_branch.is_none())
1188 {
1189 Ok(Some(BranchDeletionSafety {
1190 unpushed_commits,
1191 remote_tracking_branch,
1192 is_merged_to_main,
1193 main_branch_name,
1194 }))
1195 } else {
1196 Ok(None)
1197 }
1198 }
1199
1200 fn handle_branch_deletion_confirmation(
1202 &self,
1203 branch_name: &str,
1204 safety_info: &BranchDeletionSafety,
1205 ) -> Result<()> {
1206 if std::env::var("CI").is_ok() || std::env::var("BRANCH_DELETE_NO_CONFIRM").is_ok() {
1208 return Err(CascadeError::branch(
1209 format!(
1210 "Branch '{branch_name}' has {} unpushed commits and cannot be deleted in non-interactive mode. Use --force to override.",
1211 safety_info.unpushed_commits.len()
1212 )
1213 ));
1214 }
1215
1216 println!("\n⚠️ BRANCH DELETION WARNING ⚠️");
1218 println!("Branch '{branch_name}' has potential issues:");
1219
1220 if !safety_info.unpushed_commits.is_empty() {
1221 println!(
1222 "\n🔍 Unpushed commits ({} total):",
1223 safety_info.unpushed_commits.len()
1224 );
1225
1226 for (i, commit_id) in safety_info.unpushed_commits.iter().take(5).enumerate() {
1228 if let Ok(commit) = self.repo.find_commit(Oid::from_str(commit_id).unwrap()) {
1229 let short_hash = &commit_id[..8];
1230 let summary = commit.summary().unwrap_or("<no message>");
1231 println!(" {}. {} - {}", i + 1, short_hash, summary);
1232 }
1233 }
1234
1235 if safety_info.unpushed_commits.len() > 5 {
1236 println!(
1237 " ... and {} more commits",
1238 safety_info.unpushed_commits.len() - 5
1239 );
1240 }
1241 }
1242
1243 if !safety_info.is_merged_to_main {
1244 println!("\n📋 Branch status:");
1245 println!(" • Not merged to '{}'", safety_info.main_branch_name);
1246 if let Some(ref remote) = safety_info.remote_tracking_branch {
1247 println!(" • Remote tracking branch: {remote}");
1248 } else {
1249 println!(" • No remote tracking branch");
1250 }
1251 }
1252
1253 println!("\n💡 Safer alternatives:");
1254 if !safety_info.unpushed_commits.is_empty() {
1255 if let Some(ref _remote) = safety_info.remote_tracking_branch {
1256 println!(" • Push commits first: git push origin {branch_name}");
1257 } else {
1258 println!(" • Create and push to remote: git push -u origin {branch_name}");
1259 }
1260 }
1261 if !safety_info.is_merged_to_main {
1262 println!(
1263 " • Merge to {} first: git checkout {} && git merge {branch_name}",
1264 safety_info.main_branch_name, safety_info.main_branch_name
1265 );
1266 }
1267
1268 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
1269 .with_prompt("Do you want to proceed with deleting this branch?")
1270 .default(false)
1271 .interact()
1272 .map_err(|e| CascadeError::branch(format!("Failed to get user confirmation: {e}")))?;
1273
1274 if !confirmed {
1275 return Err(CascadeError::branch(
1276 "Branch deletion cancelled by user. Use --force to bypass this check.".to_string(),
1277 ));
1278 }
1279
1280 Ok(())
1281 }
1282
1283 fn detect_main_branch(&self) -> Result<String> {
1285 let main_candidates = ["main", "master", "develop", "trunk"];
1286
1287 for candidate in &main_candidates {
1288 if self
1289 .repo
1290 .find_branch(candidate, git2::BranchType::Local)
1291 .is_ok()
1292 {
1293 return Ok(candidate.to_string());
1294 }
1295 }
1296
1297 if let Ok(head) = self.repo.head() {
1299 if let Some(name) = head.shorthand() {
1300 return Ok(name.to_string());
1301 }
1302 }
1303
1304 Ok("main".to_string())
1306 }
1307
1308 fn is_branch_merged_to_main(&self, branch_name: &str, main_branch: &str) -> Result<bool> {
1310 match self.get_commits_between(main_branch, branch_name) {
1312 Ok(commits) => Ok(commits.is_empty()),
1313 Err(_) => {
1314 Ok(false)
1316 }
1317 }
1318 }
1319
1320 fn get_remote_tracking_branch(&self, branch_name: &str) -> Option<String> {
1322 let remote_candidates = [
1324 format!("origin/{branch_name}"),
1325 format!("remotes/origin/{branch_name}"),
1326 ];
1327
1328 for candidate in &remote_candidates {
1329 if self
1330 .repo
1331 .find_reference(&format!(
1332 "refs/remotes/{}",
1333 candidate.replace("remotes/", "")
1334 ))
1335 .is_ok()
1336 {
1337 return Some(candidate.clone());
1338 }
1339 }
1340
1341 None
1342 }
1343
1344 fn check_checkout_safety(&self, _target: &str) -> Result<Option<CheckoutSafety>> {
1346 let is_dirty = self.is_dirty()?;
1348 if !is_dirty {
1349 return Ok(None);
1351 }
1352
1353 let current_branch = self.get_current_branch().ok();
1355
1356 let modified_files = self.get_modified_files()?;
1358 let staged_files = self.get_staged_files()?;
1359 let untracked_files = self.get_untracked_files()?;
1360
1361 let has_uncommitted_changes = !modified_files.is_empty() || !staged_files.is_empty();
1362
1363 if has_uncommitted_changes || !untracked_files.is_empty() {
1364 return Ok(Some(CheckoutSafety {
1365 has_uncommitted_changes,
1366 modified_files,
1367 staged_files,
1368 untracked_files,
1369 stash_created: None,
1370 current_branch,
1371 }));
1372 }
1373
1374 Ok(None)
1375 }
1376
1377 fn handle_checkout_confirmation(
1379 &self,
1380 target: &str,
1381 safety_info: &CheckoutSafety,
1382 ) -> Result<()> {
1383 let is_ci = std::env::var("CI").is_ok();
1385 let no_confirm = std::env::var("CHECKOUT_NO_CONFIRM").is_ok();
1386 let is_non_interactive = is_ci || no_confirm;
1387
1388 if is_non_interactive {
1389 return Err(CascadeError::branch(
1390 format!(
1391 "Cannot checkout '{target}' with uncommitted changes in non-interactive mode. Commit your changes or use stash first."
1392 )
1393 ));
1394 }
1395
1396 println!("\n⚠️ CHECKOUT WARNING ⚠️");
1398 println!("You have uncommitted changes that could be lost:");
1399
1400 if !safety_info.modified_files.is_empty() {
1401 println!(
1402 "\n📝 Modified files ({}):",
1403 safety_info.modified_files.len()
1404 );
1405 for file in safety_info.modified_files.iter().take(10) {
1406 println!(" - {file}");
1407 }
1408 if safety_info.modified_files.len() > 10 {
1409 println!(" ... and {} more", safety_info.modified_files.len() - 10);
1410 }
1411 }
1412
1413 if !safety_info.staged_files.is_empty() {
1414 println!("\n📁 Staged files ({}):", safety_info.staged_files.len());
1415 for file in safety_info.staged_files.iter().take(10) {
1416 println!(" - {file}");
1417 }
1418 if safety_info.staged_files.len() > 10 {
1419 println!(" ... and {} more", safety_info.staged_files.len() - 10);
1420 }
1421 }
1422
1423 if !safety_info.untracked_files.is_empty() {
1424 println!(
1425 "\n❓ Untracked files ({}):",
1426 safety_info.untracked_files.len()
1427 );
1428 for file in safety_info.untracked_files.iter().take(5) {
1429 println!(" - {file}");
1430 }
1431 if safety_info.untracked_files.len() > 5 {
1432 println!(" ... and {} more", safety_info.untracked_files.len() - 5);
1433 }
1434 }
1435
1436 println!("\n🔄 Options:");
1437 println!("1. Stash changes and checkout (recommended)");
1438 println!("2. Force checkout (WILL LOSE UNCOMMITTED CHANGES)");
1439 println!("3. Cancel checkout");
1440
1441 let confirmation = Confirm::with_theme(&ColorfulTheme::default())
1442 .with_prompt("Would you like to stash your changes and proceed with checkout?")
1443 .interact()
1444 .map_err(|e| CascadeError::branch(format!("Could not get user confirmation: {e}")))?;
1445
1446 if confirmation {
1447 let stash_message = format!(
1449 "Auto-stash before checkout to {} at {}",
1450 target,
1451 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
1452 );
1453
1454 match self.create_stash(&stash_message) {
1455 Ok(stash_oid) => {
1456 println!("✅ Created stash: {stash_message} ({stash_oid})");
1457 println!("💡 You can restore with: git stash pop");
1458 }
1459 Err(e) => {
1460 println!("❌ Failed to create stash: {e}");
1461
1462 let force_confirm = Confirm::with_theme(&ColorfulTheme::default())
1463 .with_prompt("Stash failed. Force checkout anyway? (WILL LOSE CHANGES)")
1464 .interact()
1465 .map_err(|e| {
1466 CascadeError::branch(format!("Could not get confirmation: {e}"))
1467 })?;
1468
1469 if !force_confirm {
1470 return Err(CascadeError::branch(
1471 "Checkout cancelled by user".to_string(),
1472 ));
1473 }
1474 }
1475 }
1476 } else {
1477 return Err(CascadeError::branch(
1478 "Checkout cancelled by user".to_string(),
1479 ));
1480 }
1481
1482 Ok(())
1483 }
1484
1485 fn create_stash(&self, message: &str) -> Result<String> {
1487 warn!("Automatic stashing not yet implemented - please stash manually");
1491 Err(CascadeError::branch(format!(
1492 "Please manually stash your changes first: git stash push -m \"{message}\""
1493 )))
1494 }
1495
1496 fn get_modified_files(&self) -> Result<Vec<String>> {
1498 let mut opts = git2::StatusOptions::new();
1499 opts.include_untracked(false).include_ignored(false);
1500
1501 let statuses = self
1502 .repo
1503 .statuses(Some(&mut opts))
1504 .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
1505
1506 let mut modified_files = Vec::new();
1507 for status in statuses.iter() {
1508 let flags = status.status();
1509 if flags.contains(git2::Status::WT_MODIFIED) || flags.contains(git2::Status::WT_DELETED)
1510 {
1511 if let Some(path) = status.path() {
1512 modified_files.push(path.to_string());
1513 }
1514 }
1515 }
1516
1517 Ok(modified_files)
1518 }
1519
1520 fn get_staged_files(&self) -> Result<Vec<String>> {
1522 let mut opts = git2::StatusOptions::new();
1523 opts.include_untracked(false).include_ignored(false);
1524
1525 let statuses = self
1526 .repo
1527 .statuses(Some(&mut opts))
1528 .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
1529
1530 let mut staged_files = Vec::new();
1531 for status in statuses.iter() {
1532 let flags = status.status();
1533 if flags.contains(git2::Status::INDEX_MODIFIED)
1534 || flags.contains(git2::Status::INDEX_NEW)
1535 || flags.contains(git2::Status::INDEX_DELETED)
1536 {
1537 if let Some(path) = status.path() {
1538 staged_files.push(path.to_string());
1539 }
1540 }
1541 }
1542
1543 Ok(staged_files)
1544 }
1545
1546 fn count_commits_between(&self, from: &str, to: &str) -> Result<usize> {
1548 let commits = self.get_commits_between(from, to)?;
1549 Ok(commits.len())
1550 }
1551
1552 pub fn resolve_reference(&self, reference: &str) -> Result<git2::Commit<'_>> {
1554 if let Ok(oid) = Oid::from_str(reference) {
1556 if let Ok(commit) = self.repo.find_commit(oid) {
1557 return Ok(commit);
1558 }
1559 }
1560
1561 let obj = self.repo.revparse_single(reference).map_err(|e| {
1563 CascadeError::branch(format!("Could not resolve reference '{reference}': {e}"))
1564 })?;
1565
1566 obj.peel_to_commit().map_err(|e| {
1567 CascadeError::branch(format!(
1568 "Reference '{reference}' does not point to a commit: {e}"
1569 ))
1570 })
1571 }
1572
1573 pub fn reset_soft(&self, target_ref: &str) -> Result<()> {
1575 let target_commit = self.resolve_reference(target_ref)?;
1576
1577 self.repo
1578 .reset(target_commit.as_object(), git2::ResetType::Soft, None)
1579 .map_err(CascadeError::Git)?;
1580
1581 Ok(())
1582 }
1583
1584 pub fn find_branch_containing_commit(&self, commit_hash: &str) -> Result<String> {
1586 let oid = Oid::from_str(commit_hash).map_err(|e| {
1587 CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
1588 })?;
1589
1590 let branches = self
1592 .repo
1593 .branches(Some(git2::BranchType::Local))
1594 .map_err(CascadeError::Git)?;
1595
1596 for branch_result in branches {
1597 let (branch, _) = branch_result.map_err(CascadeError::Git)?;
1598
1599 if let Some(branch_name) = branch.name().map_err(CascadeError::Git)? {
1600 if let Ok(branch_head) = branch.get().peel_to_commit() {
1602 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
1604 revwalk.push(branch_head.id()).map_err(CascadeError::Git)?;
1605
1606 for commit_oid in revwalk {
1607 let commit_oid = commit_oid.map_err(CascadeError::Git)?;
1608 if commit_oid == oid {
1609 return Ok(branch_name.to_string());
1610 }
1611 }
1612 }
1613 }
1614 }
1615
1616 Err(CascadeError::branch(format!(
1618 "Commit {commit_hash} not found in any local branch"
1619 )))
1620 }
1621
1622 pub async fn fetch_async(&self) -> Result<()> {
1626 let repo_path = self.path.clone();
1627 crate::utils::async_ops::run_git_operation(move || {
1628 let repo = GitRepository::open(&repo_path)?;
1629 repo.fetch()
1630 })
1631 .await
1632 }
1633
1634 pub async fn pull_async(&self, branch: &str) -> Result<()> {
1636 let repo_path = self.path.clone();
1637 let branch_name = branch.to_string();
1638 crate::utils::async_ops::run_git_operation(move || {
1639 let repo = GitRepository::open(&repo_path)?;
1640 repo.pull(&branch_name)
1641 })
1642 .await
1643 }
1644
1645 pub async fn push_branch_async(&self, branch_name: &str) -> Result<()> {
1647 let repo_path = self.path.clone();
1648 let branch = branch_name.to_string();
1649 crate::utils::async_ops::run_git_operation(move || {
1650 let repo = GitRepository::open(&repo_path)?;
1651 repo.push(&branch)
1652 })
1653 .await
1654 }
1655
1656 pub async fn cherry_pick_commit_async(&self, commit_hash: &str) -> Result<String> {
1658 let repo_path = self.path.clone();
1659 let hash = commit_hash.to_string();
1660 crate::utils::async_ops::run_git_operation(move || {
1661 let repo = GitRepository::open(&repo_path)?;
1662 repo.cherry_pick(&hash)
1663 })
1664 .await
1665 }
1666
1667 pub async fn get_commit_hashes_between_async(
1669 &self,
1670 from: &str,
1671 to: &str,
1672 ) -> Result<Vec<String>> {
1673 let repo_path = self.path.clone();
1674 let from_str = from.to_string();
1675 let to_str = to.to_string();
1676 crate::utils::async_ops::run_git_operation(move || {
1677 let repo = GitRepository::open(&repo_path)?;
1678 let commits = repo.get_commits_between(&from_str, &to_str)?;
1679 Ok(commits.into_iter().map(|c| c.id().to_string()).collect())
1680 })
1681 .await
1682 }
1683
1684 pub fn reset_branch_to_commit(&self, branch_name: &str, commit_hash: &str) -> Result<()> {
1686 info!(
1687 "Resetting branch '{}' to commit {}",
1688 branch_name,
1689 &commit_hash[..8]
1690 );
1691
1692 let target_oid = git2::Oid::from_str(commit_hash).map_err(|e| {
1694 CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
1695 })?;
1696
1697 let _target_commit = self.repo.find_commit(target_oid).map_err(|e| {
1698 CascadeError::branch(format!("Could not find commit '{commit_hash}': {e}"))
1699 })?;
1700
1701 let _branch = self
1703 .repo
1704 .find_branch(branch_name, git2::BranchType::Local)
1705 .map_err(|e| {
1706 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
1707 })?;
1708
1709 let branch_ref_name = format!("refs/heads/{branch_name}");
1711 self.repo
1712 .reference(
1713 &branch_ref_name,
1714 target_oid,
1715 true,
1716 &format!("Reset {branch_name} to {commit_hash}"),
1717 )
1718 .map_err(|e| {
1719 CascadeError::branch(format!(
1720 "Could not reset branch '{branch_name}' to commit '{commit_hash}': {e}"
1721 ))
1722 })?;
1723
1724 tracing::info!(
1725 "Successfully reset branch '{}' to commit {}",
1726 branch_name,
1727 &commit_hash[..8]
1728 );
1729 Ok(())
1730 }
1731}
1732
1733#[cfg(test)]
1734mod tests {
1735 use super::*;
1736 use std::process::Command;
1737 use tempfile::TempDir;
1738
1739 fn create_test_repo() -> (TempDir, PathBuf) {
1740 let temp_dir = TempDir::new().unwrap();
1741 let repo_path = temp_dir.path().to_path_buf();
1742
1743 Command::new("git")
1745 .args(["init"])
1746 .current_dir(&repo_path)
1747 .output()
1748 .unwrap();
1749 Command::new("git")
1750 .args(["config", "user.name", "Test"])
1751 .current_dir(&repo_path)
1752 .output()
1753 .unwrap();
1754 Command::new("git")
1755 .args(["config", "user.email", "test@test.com"])
1756 .current_dir(&repo_path)
1757 .output()
1758 .unwrap();
1759
1760 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
1762 Command::new("git")
1763 .args(["add", "."])
1764 .current_dir(&repo_path)
1765 .output()
1766 .unwrap();
1767 Command::new("git")
1768 .args(["commit", "-m", "Initial commit"])
1769 .current_dir(&repo_path)
1770 .output()
1771 .unwrap();
1772
1773 (temp_dir, repo_path)
1774 }
1775
1776 fn create_commit(repo_path: &PathBuf, message: &str, filename: &str) {
1777 let file_path = repo_path.join(filename);
1778 std::fs::write(&file_path, format!("Content for {filename}\n")).unwrap();
1779
1780 Command::new("git")
1781 .args(["add", filename])
1782 .current_dir(repo_path)
1783 .output()
1784 .unwrap();
1785 Command::new("git")
1786 .args(["commit", "-m", message])
1787 .current_dir(repo_path)
1788 .output()
1789 .unwrap();
1790 }
1791
1792 #[test]
1793 fn test_repository_info() {
1794 let (_temp_dir, repo_path) = create_test_repo();
1795 let repo = GitRepository::open(&repo_path).unwrap();
1796
1797 let info = repo.get_info().unwrap();
1798 assert!(!info.is_dirty); assert!(
1800 info.head_branch == Some("master".to_string())
1801 || info.head_branch == Some("main".to_string()),
1802 "Expected default branch to be 'master' or 'main', got {:?}",
1803 info.head_branch
1804 );
1805 assert!(info.head_commit.is_some()); assert!(info.untracked_files.is_empty()); }
1808
1809 #[test]
1810 fn test_force_push_branch_basic() {
1811 let (_temp_dir, repo_path) = create_test_repo();
1812 let repo = GitRepository::open(&repo_path).unwrap();
1813
1814 let default_branch = repo.get_current_branch().unwrap();
1816
1817 create_commit(&repo_path, "Feature commit 1", "feature1.rs");
1819 Command::new("git")
1820 .args(["checkout", "-b", "source-branch"])
1821 .current_dir(&repo_path)
1822 .output()
1823 .unwrap();
1824 create_commit(&repo_path, "Feature commit 2", "feature2.rs");
1825
1826 Command::new("git")
1828 .args(["checkout", &default_branch])
1829 .current_dir(&repo_path)
1830 .output()
1831 .unwrap();
1832 Command::new("git")
1833 .args(["checkout", "-b", "target-branch"])
1834 .current_dir(&repo_path)
1835 .output()
1836 .unwrap();
1837 create_commit(&repo_path, "Target commit", "target.rs");
1838
1839 let result = repo.force_push_branch("target-branch", "source-branch");
1841
1842 assert!(result.is_ok() || result.is_err()); }
1846
1847 #[test]
1848 fn test_force_push_branch_nonexistent_branches() {
1849 let (_temp_dir, repo_path) = create_test_repo();
1850 let repo = GitRepository::open(&repo_path).unwrap();
1851
1852 let default_branch = repo.get_current_branch().unwrap();
1854
1855 let result = repo.force_push_branch("target", "nonexistent-source");
1857 assert!(result.is_err());
1858
1859 let result = repo.force_push_branch("nonexistent-target", &default_branch);
1861 assert!(result.is_err());
1862 }
1863
1864 #[test]
1865 fn test_force_push_workflow_simulation() {
1866 let (_temp_dir, repo_path) = create_test_repo();
1867 let repo = GitRepository::open(&repo_path).unwrap();
1868
1869 Command::new("git")
1872 .args(["checkout", "-b", "feature-auth"])
1873 .current_dir(&repo_path)
1874 .output()
1875 .unwrap();
1876 create_commit(&repo_path, "Add authentication", "auth.rs");
1877
1878 Command::new("git")
1880 .args(["checkout", "-b", "feature-auth-v2"])
1881 .current_dir(&repo_path)
1882 .output()
1883 .unwrap();
1884 create_commit(&repo_path, "Fix auth validation", "auth.rs");
1885
1886 let result = repo.force_push_branch("feature-auth", "feature-auth-v2");
1888
1889 match result {
1891 Ok(_) => {
1892 Command::new("git")
1894 .args(["checkout", "feature-auth"])
1895 .current_dir(&repo_path)
1896 .output()
1897 .unwrap();
1898 let log_output = Command::new("git")
1899 .args(["log", "--oneline", "-2"])
1900 .current_dir(&repo_path)
1901 .output()
1902 .unwrap();
1903 let log_str = String::from_utf8_lossy(&log_output.stdout);
1904 assert!(
1905 log_str.contains("Fix auth validation")
1906 || log_str.contains("Add authentication")
1907 );
1908 }
1909 Err(_) => {
1910 }
1913 }
1914 }
1915
1916 #[test]
1917 fn test_branch_operations() {
1918 let (_temp_dir, repo_path) = create_test_repo();
1919 let repo = GitRepository::open(&repo_path).unwrap();
1920
1921 let current = repo.get_current_branch().unwrap();
1923 assert!(
1924 current == "master" || current == "main",
1925 "Expected default branch to be 'master' or 'main', got '{current}'"
1926 );
1927
1928 Command::new("git")
1930 .args(["checkout", "-b", "test-branch"])
1931 .current_dir(&repo_path)
1932 .output()
1933 .unwrap();
1934 let current = repo.get_current_branch().unwrap();
1935 assert_eq!(current, "test-branch");
1936 }
1937
1938 #[test]
1939 fn test_commit_operations() {
1940 let (_temp_dir, repo_path) = create_test_repo();
1941 let repo = GitRepository::open(&repo_path).unwrap();
1942
1943 let head = repo.get_head_commit().unwrap();
1945 assert_eq!(head.message().unwrap().trim(), "Initial commit");
1946
1947 let hash = head.id().to_string();
1949 let same_commit = repo.get_commit(&hash).unwrap();
1950 assert_eq!(head.id(), same_commit.id());
1951 }
1952
1953 #[test]
1954 fn test_checkout_safety_clean_repo() {
1955 let (_temp_dir, repo_path) = create_test_repo();
1956 let repo = GitRepository::open(&repo_path).unwrap();
1957
1958 create_commit(&repo_path, "Second commit", "test.txt");
1960 Command::new("git")
1961 .args(["checkout", "-b", "test-branch"])
1962 .current_dir(&repo_path)
1963 .output()
1964 .unwrap();
1965
1966 let safety_result = repo.check_checkout_safety("main");
1968 assert!(safety_result.is_ok());
1969 assert!(safety_result.unwrap().is_none()); }
1971
1972 #[test]
1973 fn test_checkout_safety_with_modified_files() {
1974 let (_temp_dir, repo_path) = create_test_repo();
1975 let repo = GitRepository::open(&repo_path).unwrap();
1976
1977 Command::new("git")
1979 .args(["checkout", "-b", "test-branch"])
1980 .current_dir(&repo_path)
1981 .output()
1982 .unwrap();
1983
1984 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
1986
1987 let safety_result = repo.check_checkout_safety("main");
1989 assert!(safety_result.is_ok());
1990 let safety_info = safety_result.unwrap();
1991 assert!(safety_info.is_some());
1992
1993 let info = safety_info.unwrap();
1994 assert!(!info.modified_files.is_empty());
1995 assert!(info.modified_files.contains(&"README.md".to_string()));
1996 }
1997
1998 #[test]
1999 fn test_unsafe_checkout_methods() {
2000 let (_temp_dir, repo_path) = create_test_repo();
2001 let repo = GitRepository::open(&repo_path).unwrap();
2002
2003 create_commit(&repo_path, "Second commit", "test.txt");
2005 Command::new("git")
2006 .args(["checkout", "-b", "test-branch"])
2007 .current_dir(&repo_path)
2008 .output()
2009 .unwrap();
2010
2011 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2013
2014 let _result = repo.checkout_branch_unsafe("master");
2016 let head_commit = repo.get_head_commit().unwrap();
2021 let commit_hash = head_commit.id().to_string();
2022 let _result = repo.checkout_commit_unsafe(&commit_hash);
2023 }
2025
2026 #[test]
2027 fn test_get_modified_files() {
2028 let (_temp_dir, repo_path) = create_test_repo();
2029 let repo = GitRepository::open(&repo_path).unwrap();
2030
2031 let modified = repo.get_modified_files().unwrap();
2033 assert!(modified.is_empty());
2034
2035 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2037
2038 let modified = repo.get_modified_files().unwrap();
2040 assert_eq!(modified.len(), 1);
2041 assert!(modified.contains(&"README.md".to_string()));
2042 }
2043
2044 #[test]
2045 fn test_get_staged_files() {
2046 let (_temp_dir, repo_path) = create_test_repo();
2047 let repo = GitRepository::open(&repo_path).unwrap();
2048
2049 let staged = repo.get_staged_files().unwrap();
2051 assert!(staged.is_empty());
2052
2053 std::fs::write(repo_path.join("staged.txt"), "Staged content").unwrap();
2055 Command::new("git")
2056 .args(["add", "staged.txt"])
2057 .current_dir(&repo_path)
2058 .output()
2059 .unwrap();
2060
2061 let staged = repo.get_staged_files().unwrap();
2063 assert_eq!(staged.len(), 1);
2064 assert!(staged.contains(&"staged.txt".to_string()));
2065 }
2066
2067 #[test]
2068 fn test_create_stash_fallback() {
2069 let (_temp_dir, repo_path) = create_test_repo();
2070 let repo = GitRepository::open(&repo_path).unwrap();
2071
2072 let result = repo.create_stash("test stash");
2074 assert!(result.is_err());
2075 let error_msg = result.unwrap_err().to_string();
2076 assert!(error_msg.contains("git stash push"));
2077 }
2078
2079 #[test]
2080 fn test_delete_branch_unsafe() {
2081 let (_temp_dir, repo_path) = create_test_repo();
2082 let repo = GitRepository::open(&repo_path).unwrap();
2083
2084 create_commit(&repo_path, "Second commit", "test.txt");
2086 Command::new("git")
2087 .args(["checkout", "-b", "test-branch"])
2088 .current_dir(&repo_path)
2089 .output()
2090 .unwrap();
2091
2092 create_commit(&repo_path, "Branch-specific commit", "branch.txt");
2094
2095 Command::new("git")
2097 .args(["checkout", "master"])
2098 .current_dir(&repo_path)
2099 .output()
2100 .unwrap();
2101
2102 let result = repo.delete_branch_unsafe("test-branch");
2105 let _ = result; }
2109
2110 #[test]
2111 fn test_force_push_unsafe() {
2112 let (_temp_dir, repo_path) = create_test_repo();
2113 let repo = GitRepository::open(&repo_path).unwrap();
2114
2115 create_commit(&repo_path, "Second commit", "test.txt");
2117 Command::new("git")
2118 .args(["checkout", "-b", "test-branch"])
2119 .current_dir(&repo_path)
2120 .output()
2121 .unwrap();
2122
2123 let _result = repo.force_push_branch_unsafe("test-branch", "test-branch");
2126 }
2128}