1use crate::errors::{CascadeError, Result};
2use chrono;
3use dialoguer::{theme::ColorfulTheme, Confirm};
4use git2::{Oid, Repository, Signature};
5use std::path::{Path, PathBuf};
6use tracing::{info, warn};
7
8#[derive(Debug, Clone)]
10pub struct RepositoryInfo {
11 pub path: PathBuf,
12 pub head_branch: Option<String>,
13 pub head_commit: Option<String>,
14 pub is_dirty: bool,
15 pub untracked_files: Vec<String>,
16}
17
18#[derive(Debug, Clone)]
20struct ForceBackupInfo {
21 pub backup_branch_name: String,
22 pub remote_commit_id: String,
23 #[allow(dead_code)] pub commits_that_would_be_lost: usize,
25}
26
27#[derive(Debug, Clone)]
29struct BranchDeletionSafety {
30 pub unpushed_commits: Vec<String>,
31 pub remote_tracking_branch: Option<String>,
32 pub is_merged_to_main: bool,
33 pub main_branch_name: String,
34}
35
36#[derive(Debug, Clone)]
38struct CheckoutSafety {
39 #[allow(dead_code)] pub has_uncommitted_changes: bool,
41 pub modified_files: Vec<String>,
42 pub staged_files: Vec<String>,
43 pub untracked_files: Vec<String>,
44 #[allow(dead_code)] pub stash_created: Option<String>,
46 #[allow(dead_code)] pub current_branch: Option<String>,
48}
49
50#[derive(Debug, Clone)]
52pub struct GitSslConfig {
53 pub accept_invalid_certs: bool,
54 pub ca_bundle_path: Option<String>,
55}
56
57pub struct GitRepository {
63 repo: Repository,
64 path: PathBuf,
65 ssl_config: Option<GitSslConfig>,
66}
67
68impl GitRepository {
69 pub fn open(path: &Path) -> Result<Self> {
72 let repo = Repository::discover(path)
73 .map_err(|e| CascadeError::config(format!("Not a git repository: {e}")))?;
74
75 let workdir = repo
76 .workdir()
77 .ok_or_else(|| CascadeError::config("Repository has no working directory"))?
78 .to_path_buf();
79
80 let ssl_config = Self::load_ssl_config_from_cascade(&workdir);
82
83 Ok(Self {
84 repo,
85 path: workdir,
86 ssl_config,
87 })
88 }
89
90 fn load_ssl_config_from_cascade(repo_path: &Path) -> Option<GitSslConfig> {
92 let config_dir = crate::config::get_repo_config_dir(repo_path).ok()?;
94 let config_path = config_dir.join("config.json");
95 let settings = crate::config::Settings::load_from_file(&config_path).ok()?;
96
97 if settings.bitbucket.accept_invalid_certs.is_some()
99 || settings.bitbucket.ca_bundle_path.is_some()
100 {
101 Some(GitSslConfig {
102 accept_invalid_certs: settings.bitbucket.accept_invalid_certs.unwrap_or(false),
103 ca_bundle_path: settings.bitbucket.ca_bundle_path,
104 })
105 } else {
106 None
107 }
108 }
109
110 pub fn get_info(&self) -> Result<RepositoryInfo> {
112 let head_branch = self.get_current_branch().ok();
113 let head_commit = self.get_head_commit_hash().ok();
114 let is_dirty = self.is_dirty()?;
115 let untracked_files = self.get_untracked_files()?;
116
117 Ok(RepositoryInfo {
118 path: self.path.clone(),
119 head_branch,
120 head_commit,
121 is_dirty,
122 untracked_files,
123 })
124 }
125
126 pub fn get_current_branch(&self) -> Result<String> {
128 let head = self
129 .repo
130 .head()
131 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
132
133 if let Some(name) = head.shorthand() {
134 Ok(name.to_string())
135 } else {
136 let commit = head
138 .peel_to_commit()
139 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))?;
140 Ok(format!("HEAD@{}", commit.id()))
141 }
142 }
143
144 pub fn get_head_commit_hash(&self) -> Result<String> {
146 let head = self
147 .repo
148 .head()
149 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
150
151 let commit = head
152 .peel_to_commit()
153 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))?;
154
155 Ok(commit.id().to_string())
156 }
157
158 pub fn is_dirty(&self) -> Result<bool> {
160 let statuses = self.repo.statuses(None).map_err(CascadeError::Git)?;
161
162 for status in statuses.iter() {
163 let flags = status.status();
164
165 if flags.intersects(
167 git2::Status::INDEX_MODIFIED
168 | git2::Status::INDEX_NEW
169 | git2::Status::INDEX_DELETED
170 | git2::Status::WT_MODIFIED
171 | git2::Status::WT_NEW
172 | git2::Status::WT_DELETED,
173 ) {
174 return Ok(true);
175 }
176 }
177
178 Ok(false)
179 }
180
181 pub fn get_untracked_files(&self) -> Result<Vec<String>> {
183 let statuses = self.repo.statuses(None).map_err(CascadeError::Git)?;
184
185 let mut untracked = Vec::new();
186 for status in statuses.iter() {
187 if status.status().contains(git2::Status::WT_NEW) {
188 if let Some(path) = status.path() {
189 untracked.push(path.to_string());
190 }
191 }
192 }
193
194 Ok(untracked)
195 }
196
197 pub fn create_branch(&self, name: &str, target: Option<&str>) -> Result<()> {
199 let target_commit = if let Some(target) = target {
200 let target_obj = self.repo.revparse_single(target).map_err(|e| {
202 CascadeError::branch(format!("Could not find target '{target}': {e}"))
203 })?;
204 target_obj.peel_to_commit().map_err(|e| {
205 CascadeError::branch(format!("Target '{target}' is not a commit: {e}"))
206 })?
207 } else {
208 let head = self
210 .repo
211 .head()
212 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
213 head.peel_to_commit()
214 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))?
215 };
216
217 self.repo
218 .branch(name, &target_commit, false)
219 .map_err(|e| CascadeError::branch(format!("Could not create branch '{name}': {e}")))?;
220
221 tracing::info!("Created branch '{}'", name);
222 Ok(())
223 }
224
225 pub fn checkout_branch(&self, name: &str) -> Result<()> {
227 self.checkout_branch_with_options(name, false)
228 }
229
230 pub fn checkout_branch_unsafe(&self, name: &str) -> Result<()> {
232 self.checkout_branch_with_options(name, true)
233 }
234
235 fn checkout_branch_with_options(&self, name: &str, force_unsafe: bool) -> Result<()> {
237 info!("Attempting to checkout branch: {}", name);
238
239 if !force_unsafe {
241 let safety_result = self.check_checkout_safety(name)?;
242 if let Some(safety_info) = safety_result {
243 self.handle_checkout_confirmation(name, &safety_info)?;
245 }
246 }
247
248 let branch = self
250 .repo
251 .find_branch(name, git2::BranchType::Local)
252 .map_err(|e| CascadeError::branch(format!("Could not find branch '{name}': {e}")))?;
253
254 let branch_ref = branch.get();
255 let tree = branch_ref.peel_to_tree().map_err(|e| {
256 CascadeError::branch(format!("Could not get tree for branch '{name}': {e}"))
257 })?;
258
259 self.repo
261 .checkout_tree(tree.as_object(), None)
262 .map_err(|e| {
263 CascadeError::branch(format!("Could not checkout branch '{name}': {e}"))
264 })?;
265
266 self.repo
268 .set_head(&format!("refs/heads/{name}"))
269 .map_err(|e| CascadeError::branch(format!("Could not update HEAD to '{name}': {e}")))?;
270
271 tracing::info!("Switched to branch '{}'", name);
272 Ok(())
273 }
274
275 pub fn checkout_commit(&self, commit_hash: &str) -> Result<()> {
277 self.checkout_commit_with_options(commit_hash, false)
278 }
279
280 pub fn checkout_commit_unsafe(&self, commit_hash: &str) -> Result<()> {
282 self.checkout_commit_with_options(commit_hash, true)
283 }
284
285 fn checkout_commit_with_options(&self, commit_hash: &str, force_unsafe: bool) -> Result<()> {
287 info!("Attempting to checkout commit: {}", commit_hash);
288
289 if !force_unsafe {
291 let safety_result = self.check_checkout_safety(&format!("commit:{commit_hash}"))?;
292 if let Some(safety_info) = safety_result {
293 self.handle_checkout_confirmation(&format!("commit {commit_hash}"), &safety_info)?;
295 }
296 }
297
298 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
299
300 let commit = self.repo.find_commit(oid).map_err(|e| {
301 CascadeError::branch(format!("Could not find commit '{commit_hash}': {e}"))
302 })?;
303
304 let tree = commit.tree().map_err(|e| {
305 CascadeError::branch(format!(
306 "Could not get tree for commit '{commit_hash}': {e}"
307 ))
308 })?;
309
310 self.repo
312 .checkout_tree(tree.as_object(), None)
313 .map_err(|e| {
314 CascadeError::branch(format!("Could not checkout commit '{commit_hash}': {e}"))
315 })?;
316
317 self.repo.set_head_detached(oid).map_err(|e| {
319 CascadeError::branch(format!(
320 "Could not update HEAD to commit '{commit_hash}': {e}"
321 ))
322 })?;
323
324 tracing::info!("Checked out commit '{}' (detached HEAD)", commit_hash);
325 Ok(())
326 }
327
328 pub fn branch_exists(&self, name: &str) -> bool {
330 self.repo.find_branch(name, git2::BranchType::Local).is_ok()
331 }
332
333 pub fn branch_exists_or_fetch(&self, name: &str) -> Result<bool> {
335 if self.repo.find_branch(name, git2::BranchType::Local).is_ok() {
337 return Ok(true);
338 }
339
340 println!("🔍 Branch '{name}' not found locally, trying to fetch from remote...");
342
343 use std::process::Command;
344
345 let fetch_result = Command::new("git")
347 .args(["fetch", "origin", &format!("{name}:{name}")])
348 .current_dir(&self.path)
349 .output();
350
351 match fetch_result {
352 Ok(output) => {
353 if output.status.success() {
354 println!("✅ Successfully fetched '{name}' from origin");
355 return Ok(self.repo.find_branch(name, git2::BranchType::Local).is_ok());
357 } else {
358 let stderr = String::from_utf8_lossy(&output.stderr);
359 tracing::debug!("Failed to fetch branch '{name}': {stderr}");
360 }
361 }
362 Err(e) => {
363 tracing::debug!("Git fetch command failed: {e}");
364 }
365 }
366
367 if name.contains('/') {
369 println!("🔍 Trying alternative fetch patterns...");
370
371 let fetch_all_result = Command::new("git")
373 .args(["fetch", "origin"])
374 .current_dir(&self.path)
375 .output();
376
377 if let Ok(output) = fetch_all_result {
378 if output.status.success() {
379 let checkout_result = Command::new("git")
381 .args(["checkout", "-b", name, &format!("origin/{name}")])
382 .current_dir(&self.path)
383 .output();
384
385 if let Ok(checkout_output) = checkout_result {
386 if checkout_output.status.success() {
387 println!(
388 "✅ Successfully created local branch '{name}' from origin/{name}"
389 );
390 return Ok(true);
391 }
392 }
393 }
394 }
395 }
396
397 Ok(false)
399 }
400
401 pub fn get_branch_commit_hash(&self, branch_name: &str) -> Result<String> {
403 let branch = self
404 .repo
405 .find_branch(branch_name, git2::BranchType::Local)
406 .map_err(|e| {
407 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
408 })?;
409
410 let commit = branch.get().peel_to_commit().map_err(|e| {
411 CascadeError::branch(format!(
412 "Could not get commit for branch '{branch_name}': {e}"
413 ))
414 })?;
415
416 Ok(commit.id().to_string())
417 }
418
419 pub fn list_branches(&self) -> Result<Vec<String>> {
421 let branches = self
422 .repo
423 .branches(Some(git2::BranchType::Local))
424 .map_err(CascadeError::Git)?;
425
426 let mut branch_names = Vec::new();
427 for branch in branches {
428 let (branch, _) = branch.map_err(CascadeError::Git)?;
429 if let Some(name) = branch.name().map_err(CascadeError::Git)? {
430 branch_names.push(name.to_string());
431 }
432 }
433
434 Ok(branch_names)
435 }
436
437 pub fn commit(&self, message: &str) -> Result<String> {
439 let signature = self.get_signature()?;
440 let tree_id = self.get_index_tree()?;
441 let tree = self.repo.find_tree(tree_id).map_err(CascadeError::Git)?;
442
443 let head = self.repo.head().map_err(CascadeError::Git)?;
445 let parent_commit = head.peel_to_commit().map_err(CascadeError::Git)?;
446
447 let commit_id = self
448 .repo
449 .commit(
450 Some("HEAD"),
451 &signature,
452 &signature,
453 message,
454 &tree,
455 &[&parent_commit],
456 )
457 .map_err(CascadeError::Git)?;
458
459 tracing::info!("Created commit: {} - {}", commit_id, message);
460 Ok(commit_id.to_string())
461 }
462
463 pub fn stage_all(&self) -> Result<()> {
465 let mut index = self.repo.index().map_err(CascadeError::Git)?;
466
467 index
468 .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
469 .map_err(CascadeError::Git)?;
470
471 index.write().map_err(CascadeError::Git)?;
472
473 tracing::debug!("Staged all changes");
474 Ok(())
475 }
476
477 pub fn path(&self) -> &Path {
479 &self.path
480 }
481
482 pub fn commit_exists(&self, commit_hash: &str) -> Result<bool> {
484 match Oid::from_str(commit_hash) {
485 Ok(oid) => match self.repo.find_commit(oid) {
486 Ok(_) => Ok(true),
487 Err(_) => Ok(false),
488 },
489 Err(_) => Ok(false),
490 }
491 }
492
493 pub fn get_head_commit(&self) -> Result<git2::Commit<'_>> {
495 let head = self
496 .repo
497 .head()
498 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
499 head.peel_to_commit()
500 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))
501 }
502
503 pub fn get_commit(&self, commit_hash: &str) -> Result<git2::Commit<'_>> {
505 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
506
507 self.repo.find_commit(oid).map_err(CascadeError::Git)
508 }
509
510 pub fn get_branch_head(&self, branch_name: &str) -> Result<String> {
512 let branch = self
513 .repo
514 .find_branch(branch_name, git2::BranchType::Local)
515 .map_err(|e| {
516 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
517 })?;
518
519 let commit = branch.get().peel_to_commit().map_err(|e| {
520 CascadeError::branch(format!(
521 "Could not get commit for branch '{branch_name}': {e}"
522 ))
523 })?;
524
525 Ok(commit.id().to_string())
526 }
527
528 fn get_signature(&self) -> Result<Signature<'_>> {
530 if let Ok(config) = self.repo.config() {
532 if let (Ok(name), Ok(email)) = (
533 config.get_string("user.name"),
534 config.get_string("user.email"),
535 ) {
536 return Signature::now(&name, &email).map_err(CascadeError::Git);
537 }
538 }
539
540 Signature::now("Cascade CLI", "cascade@example.com").map_err(CascadeError::Git)
542 }
543
544 fn configure_remote_callbacks(&self) -> Result<git2::RemoteCallbacks<'_>> {
547 let mut callbacks = git2::RemoteCallbacks::new();
548
549 callbacks.credentials(|_url, username_from_url, _allowed_types| {
551 if let Some(username) = username_from_url {
552 git2::Cred::ssh_key_from_agent(username)
554 } else {
555 git2::Cred::default()
557 }
558 });
559
560 let mut ssl_configured = false;
566
567 if let Some(ssl_config) = &self.ssl_config {
569 if ssl_config.accept_invalid_certs {
570 tracing::warn!("SSL certificate verification disabled via Cascade config (accept_invalid_certs=true)");
571 callbacks.certificate_check(|_cert, _host| {
572 Ok(git2::CertificateCheckStatus::CertificateOk)
573 });
574 ssl_configured = true;
575 } else if let Some(ca_path) = &ssl_config.ca_bundle_path {
576 tracing::info!("Using custom CA bundle from Cascade config: {}", ca_path);
577 }
581 }
582
583 if !ssl_configured {
585 if let Ok(config) = self.repo.config() {
586 let ssl_verify = config.get_bool("http.sslVerify").unwrap_or(true);
588
589 if !ssl_verify {
590 callbacks.certificate_check(|_cert, _host| {
592 tracing::warn!("SSL certificate verification disabled via git config (http.sslVerify=false)");
593 Ok(git2::CertificateCheckStatus::CertificateOk)
594 });
595 } else {
596 if let Ok(ca_path) = config.get_string("http.sslCAInfo") {
598 tracing::info!("Using custom CA bundle from git config: {}", ca_path);
601 }
602 }
603 }
604 }
605
606 Ok(callbacks)
607 }
608
609 fn get_index_tree(&self) -> Result<Oid> {
611 let mut index = self.repo.index().map_err(CascadeError::Git)?;
612
613 index.write_tree().map_err(CascadeError::Git)
614 }
615
616 pub fn get_status(&self) -> Result<git2::Statuses<'_>> {
618 self.repo.statuses(None).map_err(CascadeError::Git)
619 }
620
621 pub fn get_remote_url(&self, name: &str) -> Result<String> {
623 let remote = self.repo.find_remote(name).map_err(CascadeError::Git)?;
624
625 let url = remote.url().ok_or_else(|| {
626 CascadeError::Git(git2::Error::from_str("Remote URL is not valid UTF-8"))
627 })?;
628
629 Ok(url.to_string())
630 }
631
632 pub fn cherry_pick(&self, commit_hash: &str) -> Result<String> {
634 tracing::debug!("Cherry-picking commit {}", commit_hash);
635
636 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
637 let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
638
639 let commit_tree = commit.tree().map_err(CascadeError::Git)?;
641
642 let parent_commit = if commit.parent_count() > 0 {
644 commit.parent(0).map_err(CascadeError::Git)?
645 } else {
646 let empty_tree_oid = self.repo.treebuilder(None)?.write()?;
648 let empty_tree = self.repo.find_tree(empty_tree_oid)?;
649 let sig = self.get_signature()?;
650 return self
651 .repo
652 .commit(
653 Some("HEAD"),
654 &sig,
655 &sig,
656 commit.message().unwrap_or("Cherry-picked commit"),
657 &empty_tree,
658 &[],
659 )
660 .map(|oid| oid.to_string())
661 .map_err(CascadeError::Git);
662 };
663
664 let parent_tree = parent_commit.tree().map_err(CascadeError::Git)?;
665
666 let head_commit = self.get_head_commit()?;
668 let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
669
670 let mut index = self
672 .repo
673 .merge_trees(&parent_tree, &head_tree, &commit_tree, None)
674 .map_err(CascadeError::Git)?;
675
676 if index.has_conflicts() {
678 return Err(CascadeError::branch(format!(
679 "Cherry-pick of {commit_hash} has conflicts that need manual resolution"
680 )));
681 }
682
683 let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
685 let merged_tree = self
686 .repo
687 .find_tree(merged_tree_oid)
688 .map_err(CascadeError::Git)?;
689
690 let signature = self.get_signature()?;
692 let message = format!("Cherry-pick: {}", commit.message().unwrap_or(""));
693
694 let new_commit_oid = self
695 .repo
696 .commit(
697 Some("HEAD"),
698 &signature,
699 &signature,
700 &message,
701 &merged_tree,
702 &[&head_commit],
703 )
704 .map_err(CascadeError::Git)?;
705
706 tracing::info!("Cherry-picked {} -> {}", commit_hash, new_commit_oid);
707 Ok(new_commit_oid.to_string())
708 }
709
710 pub fn has_conflicts(&self) -> Result<bool> {
712 let index = self.repo.index().map_err(CascadeError::Git)?;
713 Ok(index.has_conflicts())
714 }
715
716 pub fn get_conflicted_files(&self) -> Result<Vec<String>> {
718 let index = self.repo.index().map_err(CascadeError::Git)?;
719
720 let mut conflicts = Vec::new();
721
722 let conflict_iter = index.conflicts().map_err(CascadeError::Git)?;
724
725 for conflict in conflict_iter {
726 let conflict = conflict.map_err(CascadeError::Git)?;
727 if let Some(our) = conflict.our {
728 if let Ok(path) = std::str::from_utf8(&our.path) {
729 conflicts.push(path.to_string());
730 }
731 } else if let Some(their) = conflict.their {
732 if let Ok(path) = std::str::from_utf8(&their.path) {
733 conflicts.push(path.to_string());
734 }
735 }
736 }
737
738 Ok(conflicts)
739 }
740
741 pub fn fetch(&self) -> Result<()> {
743 tracing::info!("Fetching from origin");
744
745 let mut remote = self
746 .repo
747 .find_remote("origin")
748 .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
749
750 let callbacks = self.configure_remote_callbacks()?;
752
753 let mut fetch_options = git2::FetchOptions::new();
755 fetch_options.remote_callbacks(callbacks);
756
757 remote
759 .fetch::<&str>(&[], Some(&mut fetch_options), None)
760 .map_err(CascadeError::Git)?;
761
762 tracing::debug!("Fetch completed successfully");
763 Ok(())
764 }
765
766 pub fn pull(&self, branch: &str) -> Result<()> {
768 tracing::info!("Pulling branch: {}", branch);
769
770 self.fetch()?;
772
773 let remote_branch_name = format!("origin/{branch}");
775 let remote_oid = self
776 .repo
777 .refname_to_id(&format!("refs/remotes/{remote_branch_name}"))
778 .map_err(|e| {
779 CascadeError::branch(format!("Remote branch {remote_branch_name} not found: {e}"))
780 })?;
781
782 let remote_commit = self
783 .repo
784 .find_commit(remote_oid)
785 .map_err(CascadeError::Git)?;
786
787 let head_commit = self.get_head_commit()?;
789
790 if head_commit.id() == remote_commit.id() {
792 tracing::debug!("Already up to date");
793 return Ok(());
794 }
795
796 let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
798 let remote_tree = remote_commit.tree().map_err(CascadeError::Git)?;
799
800 let merge_base_oid = self
802 .repo
803 .merge_base(head_commit.id(), remote_commit.id())
804 .map_err(CascadeError::Git)?;
805 let merge_base_commit = self
806 .repo
807 .find_commit(merge_base_oid)
808 .map_err(CascadeError::Git)?;
809 let merge_base_tree = merge_base_commit.tree().map_err(CascadeError::Git)?;
810
811 let mut index = self
813 .repo
814 .merge_trees(&merge_base_tree, &head_tree, &remote_tree, None)
815 .map_err(CascadeError::Git)?;
816
817 if index.has_conflicts() {
818 return Err(CascadeError::branch(
819 "Pull has conflicts that need manual resolution".to_string(),
820 ));
821 }
822
823 let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
825 let merged_tree = self
826 .repo
827 .find_tree(merged_tree_oid)
828 .map_err(CascadeError::Git)?;
829
830 let signature = self.get_signature()?;
831 let message = format!("Merge branch '{branch}' from origin");
832
833 self.repo
834 .commit(
835 Some("HEAD"),
836 &signature,
837 &signature,
838 &message,
839 &merged_tree,
840 &[&head_commit, &remote_commit],
841 )
842 .map_err(CascadeError::Git)?;
843
844 tracing::info!("Pull completed successfully");
845 Ok(())
846 }
847
848 pub fn push(&self, branch: &str) -> Result<()> {
850 tracing::info!("Pushing branch: {}", branch);
851
852 let mut remote = self
853 .repo
854 .find_remote("origin")
855 .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
856
857 let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
858
859 let callbacks = self.configure_remote_callbacks()?;
861
862 let mut push_options = git2::PushOptions::new();
864 push_options.remote_callbacks(callbacks);
865
866 remote
867 .push(&[&refspec], Some(&mut push_options))
868 .map_err(CascadeError::Git)?;
869
870 tracing::info!("Push completed successfully");
871 Ok(())
872 }
873
874 pub fn delete_branch(&self, name: &str) -> Result<()> {
876 self.delete_branch_with_options(name, false)
877 }
878
879 pub fn delete_branch_unsafe(&self, name: &str) -> Result<()> {
881 self.delete_branch_with_options(name, true)
882 }
883
884 fn delete_branch_with_options(&self, name: &str, force_unsafe: bool) -> Result<()> {
886 info!("Attempting to delete branch: {}", name);
887
888 if !force_unsafe {
890 let safety_result = self.check_branch_deletion_safety(name)?;
891 if let Some(safety_info) = safety_result {
892 self.handle_branch_deletion_confirmation(name, &safety_info)?;
894 }
895 }
896
897 let mut branch = self
898 .repo
899 .find_branch(name, git2::BranchType::Local)
900 .map_err(|e| CascadeError::branch(format!("Could not find branch '{name}': {e}")))?;
901
902 branch
903 .delete()
904 .map_err(|e| CascadeError::branch(format!("Could not delete branch '{name}': {e}")))?;
905
906 info!("Successfully deleted branch '{}'", name);
907 Ok(())
908 }
909
910 pub fn get_commits_between(&self, from: &str, to: &str) -> Result<Vec<git2::Commit<'_>>> {
912 let from_oid = self
913 .repo
914 .refname_to_id(&format!("refs/heads/{from}"))
915 .or_else(|_| Oid::from_str(from))
916 .map_err(|e| CascadeError::branch(format!("Invalid from reference '{from}': {e}")))?;
917
918 let to_oid = self
919 .repo
920 .refname_to_id(&format!("refs/heads/{to}"))
921 .or_else(|_| Oid::from_str(to))
922 .map_err(|e| CascadeError::branch(format!("Invalid to reference '{to}': {e}")))?;
923
924 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
925
926 revwalk.push(to_oid).map_err(CascadeError::Git)?;
927 revwalk.hide(from_oid).map_err(CascadeError::Git)?;
928
929 let mut commits = Vec::new();
930 for oid in revwalk {
931 let oid = oid.map_err(CascadeError::Git)?;
932 let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
933 commits.push(commit);
934 }
935
936 Ok(commits)
937 }
938
939 pub fn force_push_branch(&self, target_branch: &str, source_branch: &str) -> Result<()> {
942 self.force_push_branch_with_options(target_branch, source_branch, false)
943 }
944
945 pub fn force_push_branch_unsafe(&self, target_branch: &str, source_branch: &str) -> Result<()> {
947 self.force_push_branch_with_options(target_branch, source_branch, true)
948 }
949
950 fn force_push_branch_with_options(
952 &self,
953 target_branch: &str,
954 source_branch: &str,
955 force_unsafe: bool,
956 ) -> Result<()> {
957 info!(
958 "Force pushing {} content to {} to preserve PR history",
959 source_branch, target_branch
960 );
961
962 if !force_unsafe {
964 let safety_result = self.check_force_push_safety_enhanced(target_branch)?;
965 if let Some(backup_info) = safety_result {
966 self.create_backup_branch(target_branch, &backup_info.remote_commit_id)?;
968 info!(
969 "✅ Created backup branch: {}",
970 backup_info.backup_branch_name
971 );
972 }
973 }
974
975 let source_ref = self
977 .repo
978 .find_reference(&format!("refs/heads/{source_branch}"))
979 .map_err(|e| {
980 CascadeError::config(format!("Failed to find source branch {source_branch}: {e}"))
981 })?;
982 let source_commit = source_ref.peel_to_commit().map_err(|e| {
983 CascadeError::config(format!(
984 "Failed to get commit for source branch {source_branch}: {e}"
985 ))
986 })?;
987
988 let mut target_ref = self
990 .repo
991 .find_reference(&format!("refs/heads/{target_branch}"))
992 .map_err(|e| {
993 CascadeError::config(format!("Failed to find target branch {target_branch}: {e}"))
994 })?;
995
996 target_ref
997 .set_target(source_commit.id(), "Force push from rebase")
998 .map_err(|e| {
999 CascadeError::config(format!(
1000 "Failed to update target branch {target_branch}: {e}"
1001 ))
1002 })?;
1003
1004 let mut remote = self
1006 .repo
1007 .find_remote("origin")
1008 .map_err(|e| CascadeError::config(format!("Failed to find origin remote: {e}")))?;
1009
1010 let refspec = format!("+refs/heads/{target_branch}:refs/heads/{target_branch}");
1011
1012 let callbacks = self.configure_remote_callbacks()?;
1014
1015 let mut push_options = git2::PushOptions::new();
1017 push_options.remote_callbacks(callbacks);
1018
1019 remote
1020 .push(&[&refspec], Some(&mut push_options))
1021 .map_err(|e| {
1022 CascadeError::config(format!("Failed to force push {target_branch}: {e}"))
1023 })?;
1024
1025 info!(
1026 "✅ Successfully force pushed {} to preserve PR history",
1027 target_branch
1028 );
1029 Ok(())
1030 }
1031
1032 fn check_force_push_safety_enhanced(
1035 &self,
1036 target_branch: &str,
1037 ) -> Result<Option<ForceBackupInfo>> {
1038 match self.fetch() {
1040 Ok(_) => {}
1041 Err(e) => {
1042 warn!("Could not fetch latest changes for safety check: {}", e);
1044 }
1045 }
1046
1047 let remote_ref = format!("refs/remotes/origin/{target_branch}");
1049 let local_ref = format!("refs/heads/{target_branch}");
1050
1051 let local_commit = match self.repo.find_reference(&local_ref) {
1053 Ok(reference) => reference.peel_to_commit().ok(),
1054 Err(_) => None,
1055 };
1056
1057 let remote_commit = match self.repo.find_reference(&remote_ref) {
1058 Ok(reference) => reference.peel_to_commit().ok(),
1059 Err(_) => None,
1060 };
1061
1062 if let (Some(local), Some(remote)) = (local_commit, remote_commit) {
1064 if local.id() != remote.id() {
1065 let merge_base_oid = self
1067 .repo
1068 .merge_base(local.id(), remote.id())
1069 .map_err(|e| CascadeError::config(format!("Failed to find merge base: {e}")))?;
1070
1071 if merge_base_oid != remote.id() {
1073 let commits_to_lose = self.count_commits_between(
1074 &merge_base_oid.to_string(),
1075 &remote.id().to_string(),
1076 )?;
1077
1078 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1080 let backup_branch_name = format!("{target_branch}_backup_{timestamp}");
1081
1082 warn!(
1083 "⚠️ Force push to '{}' would overwrite {} commits on remote",
1084 target_branch, commits_to_lose
1085 );
1086
1087 if std::env::var("CI").is_ok() || std::env::var("FORCE_PUSH_NO_CONFIRM").is_ok()
1089 {
1090 info!(
1091 "Non-interactive environment detected, proceeding with backup creation"
1092 );
1093 return Ok(Some(ForceBackupInfo {
1094 backup_branch_name,
1095 remote_commit_id: remote.id().to_string(),
1096 commits_that_would_be_lost: commits_to_lose,
1097 }));
1098 }
1099
1100 println!("\n⚠️ FORCE PUSH WARNING ⚠️");
1102 println!("Force push to '{target_branch}' would overwrite {commits_to_lose} commits on remote:");
1103
1104 match self
1106 .get_commits_between(&merge_base_oid.to_string(), &remote.id().to_string())
1107 {
1108 Ok(commits) => {
1109 println!("\nCommits that would be lost:");
1110 for (i, commit) in commits.iter().take(5).enumerate() {
1111 let short_hash = &commit.id().to_string()[..8];
1112 let summary = commit.summary().unwrap_or("<no message>");
1113 println!(" {}. {} - {}", i + 1, short_hash, summary);
1114 }
1115 if commits.len() > 5 {
1116 println!(" ... and {} more commits", commits.len() - 5);
1117 }
1118 }
1119 Err(_) => {
1120 println!(" (Unable to retrieve commit details)");
1121 }
1122 }
1123
1124 println!("\nA backup branch '{backup_branch_name}' will be created before proceeding.");
1125
1126 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
1127 .with_prompt("Do you want to proceed with the force push?")
1128 .default(false)
1129 .interact()
1130 .map_err(|e| {
1131 CascadeError::config(format!("Failed to get user confirmation: {e}"))
1132 })?;
1133
1134 if !confirmed {
1135 return Err(CascadeError::config(
1136 "Force push cancelled by user. Use --force to bypass this check."
1137 .to_string(),
1138 ));
1139 }
1140
1141 return Ok(Some(ForceBackupInfo {
1142 backup_branch_name,
1143 remote_commit_id: remote.id().to_string(),
1144 commits_that_would_be_lost: commits_to_lose,
1145 }));
1146 }
1147 }
1148 }
1149
1150 Ok(None)
1151 }
1152
1153 fn create_backup_branch(&self, original_branch: &str, remote_commit_id: &str) -> Result<()> {
1155 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
1156 let backup_branch_name = format!("{original_branch}_backup_{timestamp}");
1157
1158 let commit_oid = Oid::from_str(remote_commit_id).map_err(|e| {
1160 CascadeError::config(format!("Invalid commit ID {remote_commit_id}: {e}"))
1161 })?;
1162
1163 let commit = self.repo.find_commit(commit_oid).map_err(|e| {
1165 CascadeError::config(format!("Failed to find commit {remote_commit_id}: {e}"))
1166 })?;
1167
1168 self.repo
1170 .branch(&backup_branch_name, &commit, false)
1171 .map_err(|e| {
1172 CascadeError::config(format!(
1173 "Failed to create backup branch {backup_branch_name}: {e}"
1174 ))
1175 })?;
1176
1177 info!(
1178 "✅ Created backup branch '{}' pointing to {}",
1179 backup_branch_name,
1180 &remote_commit_id[..8]
1181 );
1182 Ok(())
1183 }
1184
1185 fn check_branch_deletion_safety(
1188 &self,
1189 branch_name: &str,
1190 ) -> Result<Option<BranchDeletionSafety>> {
1191 match self.fetch() {
1193 Ok(_) => {}
1194 Err(e) => {
1195 warn!(
1196 "Could not fetch latest changes for branch deletion safety check: {}",
1197 e
1198 );
1199 }
1200 }
1201
1202 let branch = self
1204 .repo
1205 .find_branch(branch_name, git2::BranchType::Local)
1206 .map_err(|e| {
1207 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
1208 })?;
1209
1210 let _branch_commit = branch.get().peel_to_commit().map_err(|e| {
1211 CascadeError::branch(format!(
1212 "Could not get commit for branch '{branch_name}': {e}"
1213 ))
1214 })?;
1215
1216 let main_branch_name = self.detect_main_branch()?;
1218
1219 let is_merged_to_main = self.is_branch_merged_to_main(branch_name, &main_branch_name)?;
1221
1222 let remote_tracking_branch = self.get_remote_tracking_branch(branch_name);
1224
1225 let mut unpushed_commits = Vec::new();
1226
1227 if let Some(ref remote_branch) = remote_tracking_branch {
1229 match self.get_commits_between(remote_branch, branch_name) {
1230 Ok(commits) => {
1231 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1232 }
1233 Err(_) => {
1234 if !is_merged_to_main {
1236 if let Ok(commits) =
1237 self.get_commits_between(&main_branch_name, branch_name)
1238 {
1239 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1240 }
1241 }
1242 }
1243 }
1244 } else if !is_merged_to_main {
1245 if let Ok(commits) = self.get_commits_between(&main_branch_name, branch_name) {
1247 unpushed_commits = commits.iter().map(|c| c.id().to_string()).collect();
1248 }
1249 }
1250
1251 if !unpushed_commits.is_empty() || (!is_merged_to_main && remote_tracking_branch.is_none())
1253 {
1254 Ok(Some(BranchDeletionSafety {
1255 unpushed_commits,
1256 remote_tracking_branch,
1257 is_merged_to_main,
1258 main_branch_name,
1259 }))
1260 } else {
1261 Ok(None)
1262 }
1263 }
1264
1265 fn handle_branch_deletion_confirmation(
1267 &self,
1268 branch_name: &str,
1269 safety_info: &BranchDeletionSafety,
1270 ) -> Result<()> {
1271 if std::env::var("CI").is_ok() || std::env::var("BRANCH_DELETE_NO_CONFIRM").is_ok() {
1273 return Err(CascadeError::branch(
1274 format!(
1275 "Branch '{branch_name}' has {} unpushed commits and cannot be deleted in non-interactive mode. Use --force to override.",
1276 safety_info.unpushed_commits.len()
1277 )
1278 ));
1279 }
1280
1281 println!("\n⚠️ BRANCH DELETION WARNING ⚠️");
1283 println!("Branch '{branch_name}' has potential issues:");
1284
1285 if !safety_info.unpushed_commits.is_empty() {
1286 println!(
1287 "\n🔍 Unpushed commits ({} total):",
1288 safety_info.unpushed_commits.len()
1289 );
1290
1291 for (i, commit_id) in safety_info.unpushed_commits.iter().take(5).enumerate() {
1293 if let Ok(commit) = self.repo.find_commit(Oid::from_str(commit_id).unwrap()) {
1294 let short_hash = &commit_id[..8];
1295 let summary = commit.summary().unwrap_or("<no message>");
1296 println!(" {}. {} - {}", i + 1, short_hash, summary);
1297 }
1298 }
1299
1300 if safety_info.unpushed_commits.len() > 5 {
1301 println!(
1302 " ... and {} more commits",
1303 safety_info.unpushed_commits.len() - 5
1304 );
1305 }
1306 }
1307
1308 if !safety_info.is_merged_to_main {
1309 println!("\n📋 Branch status:");
1310 println!(" • Not merged to '{}'", safety_info.main_branch_name);
1311 if let Some(ref remote) = safety_info.remote_tracking_branch {
1312 println!(" • Remote tracking branch: {remote}");
1313 } else {
1314 println!(" • No remote tracking branch");
1315 }
1316 }
1317
1318 println!("\n💡 Safer alternatives:");
1319 if !safety_info.unpushed_commits.is_empty() {
1320 if let Some(ref _remote) = safety_info.remote_tracking_branch {
1321 println!(" • Push commits first: git push origin {branch_name}");
1322 } else {
1323 println!(" • Create and push to remote: git push -u origin {branch_name}");
1324 }
1325 }
1326 if !safety_info.is_merged_to_main {
1327 println!(
1328 " • Merge to {} first: git checkout {} && git merge {branch_name}",
1329 safety_info.main_branch_name, safety_info.main_branch_name
1330 );
1331 }
1332
1333 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
1334 .with_prompt("Do you want to proceed with deleting this branch?")
1335 .default(false)
1336 .interact()
1337 .map_err(|e| CascadeError::branch(format!("Failed to get user confirmation: {e}")))?;
1338
1339 if !confirmed {
1340 return Err(CascadeError::branch(
1341 "Branch deletion cancelled by user. Use --force to bypass this check.".to_string(),
1342 ));
1343 }
1344
1345 Ok(())
1346 }
1347
1348 fn detect_main_branch(&self) -> Result<String> {
1350 let main_candidates = ["main", "master", "develop", "trunk"];
1351
1352 for candidate in &main_candidates {
1353 if self
1354 .repo
1355 .find_branch(candidate, git2::BranchType::Local)
1356 .is_ok()
1357 {
1358 return Ok(candidate.to_string());
1359 }
1360 }
1361
1362 if let Ok(head) = self.repo.head() {
1364 if let Some(name) = head.shorthand() {
1365 return Ok(name.to_string());
1366 }
1367 }
1368
1369 Ok("main".to_string())
1371 }
1372
1373 fn is_branch_merged_to_main(&self, branch_name: &str, main_branch: &str) -> Result<bool> {
1375 match self.get_commits_between(main_branch, branch_name) {
1377 Ok(commits) => Ok(commits.is_empty()),
1378 Err(_) => {
1379 Ok(false)
1381 }
1382 }
1383 }
1384
1385 fn get_remote_tracking_branch(&self, branch_name: &str) -> Option<String> {
1387 let remote_candidates = [
1389 format!("origin/{branch_name}"),
1390 format!("remotes/origin/{branch_name}"),
1391 ];
1392
1393 for candidate in &remote_candidates {
1394 if self
1395 .repo
1396 .find_reference(&format!(
1397 "refs/remotes/{}",
1398 candidate.replace("remotes/", "")
1399 ))
1400 .is_ok()
1401 {
1402 return Some(candidate.clone());
1403 }
1404 }
1405
1406 None
1407 }
1408
1409 fn check_checkout_safety(&self, _target: &str) -> Result<Option<CheckoutSafety>> {
1411 let is_dirty = self.is_dirty()?;
1413 if !is_dirty {
1414 return Ok(None);
1416 }
1417
1418 let current_branch = self.get_current_branch().ok();
1420
1421 let modified_files = self.get_modified_files()?;
1423 let staged_files = self.get_staged_files()?;
1424 let untracked_files = self.get_untracked_files()?;
1425
1426 let has_uncommitted_changes = !modified_files.is_empty() || !staged_files.is_empty();
1427
1428 if has_uncommitted_changes || !untracked_files.is_empty() {
1429 return Ok(Some(CheckoutSafety {
1430 has_uncommitted_changes,
1431 modified_files,
1432 staged_files,
1433 untracked_files,
1434 stash_created: None,
1435 current_branch,
1436 }));
1437 }
1438
1439 Ok(None)
1440 }
1441
1442 fn handle_checkout_confirmation(
1444 &self,
1445 target: &str,
1446 safety_info: &CheckoutSafety,
1447 ) -> Result<()> {
1448 let is_ci = std::env::var("CI").is_ok();
1450 let no_confirm = std::env::var("CHECKOUT_NO_CONFIRM").is_ok();
1451 let is_non_interactive = is_ci || no_confirm;
1452
1453 if is_non_interactive {
1454 return Err(CascadeError::branch(
1455 format!(
1456 "Cannot checkout '{target}' with uncommitted changes in non-interactive mode. Commit your changes or use stash first."
1457 )
1458 ));
1459 }
1460
1461 println!("\n⚠️ CHECKOUT WARNING ⚠️");
1463 println!("You have uncommitted changes that could be lost:");
1464
1465 if !safety_info.modified_files.is_empty() {
1466 println!(
1467 "\n📝 Modified files ({}):",
1468 safety_info.modified_files.len()
1469 );
1470 for file in safety_info.modified_files.iter().take(10) {
1471 println!(" - {file}");
1472 }
1473 if safety_info.modified_files.len() > 10 {
1474 println!(" ... and {} more", safety_info.modified_files.len() - 10);
1475 }
1476 }
1477
1478 if !safety_info.staged_files.is_empty() {
1479 println!("\n📁 Staged files ({}):", safety_info.staged_files.len());
1480 for file in safety_info.staged_files.iter().take(10) {
1481 println!(" - {file}");
1482 }
1483 if safety_info.staged_files.len() > 10 {
1484 println!(" ... and {} more", safety_info.staged_files.len() - 10);
1485 }
1486 }
1487
1488 if !safety_info.untracked_files.is_empty() {
1489 println!(
1490 "\n❓ Untracked files ({}):",
1491 safety_info.untracked_files.len()
1492 );
1493 for file in safety_info.untracked_files.iter().take(5) {
1494 println!(" - {file}");
1495 }
1496 if safety_info.untracked_files.len() > 5 {
1497 println!(" ... and {} more", safety_info.untracked_files.len() - 5);
1498 }
1499 }
1500
1501 println!("\n🔄 Options:");
1502 println!("1. Stash changes and checkout (recommended)");
1503 println!("2. Force checkout (WILL LOSE UNCOMMITTED CHANGES)");
1504 println!("3. Cancel checkout");
1505
1506 let confirmation = Confirm::with_theme(&ColorfulTheme::default())
1507 .with_prompt("Would you like to stash your changes and proceed with checkout?")
1508 .interact()
1509 .map_err(|e| CascadeError::branch(format!("Could not get user confirmation: {e}")))?;
1510
1511 if confirmation {
1512 let stash_message = format!(
1514 "Auto-stash before checkout to {} at {}",
1515 target,
1516 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
1517 );
1518
1519 match self.create_stash(&stash_message) {
1520 Ok(stash_oid) => {
1521 println!("✅ Created stash: {stash_message} ({stash_oid})");
1522 println!("💡 You can restore with: git stash pop");
1523 }
1524 Err(e) => {
1525 println!("❌ Failed to create stash: {e}");
1526
1527 let force_confirm = Confirm::with_theme(&ColorfulTheme::default())
1528 .with_prompt("Stash failed. Force checkout anyway? (WILL LOSE CHANGES)")
1529 .interact()
1530 .map_err(|e| {
1531 CascadeError::branch(format!("Could not get confirmation: {e}"))
1532 })?;
1533
1534 if !force_confirm {
1535 return Err(CascadeError::branch(
1536 "Checkout cancelled by user".to_string(),
1537 ));
1538 }
1539 }
1540 }
1541 } else {
1542 return Err(CascadeError::branch(
1543 "Checkout cancelled by user".to_string(),
1544 ));
1545 }
1546
1547 Ok(())
1548 }
1549
1550 fn create_stash(&self, message: &str) -> Result<String> {
1552 warn!("Automatic stashing not yet implemented - please stash manually");
1556 Err(CascadeError::branch(format!(
1557 "Please manually stash your changes first: git stash push -m \"{message}\""
1558 )))
1559 }
1560
1561 fn get_modified_files(&self) -> Result<Vec<String>> {
1563 let mut opts = git2::StatusOptions::new();
1564 opts.include_untracked(false).include_ignored(false);
1565
1566 let statuses = self
1567 .repo
1568 .statuses(Some(&mut opts))
1569 .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
1570
1571 let mut modified_files = Vec::new();
1572 for status in statuses.iter() {
1573 let flags = status.status();
1574 if flags.contains(git2::Status::WT_MODIFIED) || flags.contains(git2::Status::WT_DELETED)
1575 {
1576 if let Some(path) = status.path() {
1577 modified_files.push(path.to_string());
1578 }
1579 }
1580 }
1581
1582 Ok(modified_files)
1583 }
1584
1585 fn get_staged_files(&self) -> Result<Vec<String>> {
1587 let mut opts = git2::StatusOptions::new();
1588 opts.include_untracked(false).include_ignored(false);
1589
1590 let statuses = self
1591 .repo
1592 .statuses(Some(&mut opts))
1593 .map_err(|e| CascadeError::branch(format!("Could not get repository status: {e}")))?;
1594
1595 let mut staged_files = Vec::new();
1596 for status in statuses.iter() {
1597 let flags = status.status();
1598 if flags.contains(git2::Status::INDEX_MODIFIED)
1599 || flags.contains(git2::Status::INDEX_NEW)
1600 || flags.contains(git2::Status::INDEX_DELETED)
1601 {
1602 if let Some(path) = status.path() {
1603 staged_files.push(path.to_string());
1604 }
1605 }
1606 }
1607
1608 Ok(staged_files)
1609 }
1610
1611 fn count_commits_between(&self, from: &str, to: &str) -> Result<usize> {
1613 let commits = self.get_commits_between(from, to)?;
1614 Ok(commits.len())
1615 }
1616
1617 pub fn resolve_reference(&self, reference: &str) -> Result<git2::Commit<'_>> {
1619 if let Ok(oid) = Oid::from_str(reference) {
1621 if let Ok(commit) = self.repo.find_commit(oid) {
1622 return Ok(commit);
1623 }
1624 }
1625
1626 let obj = self.repo.revparse_single(reference).map_err(|e| {
1628 CascadeError::branch(format!("Could not resolve reference '{reference}': {e}"))
1629 })?;
1630
1631 obj.peel_to_commit().map_err(|e| {
1632 CascadeError::branch(format!(
1633 "Reference '{reference}' does not point to a commit: {e}"
1634 ))
1635 })
1636 }
1637
1638 pub fn reset_soft(&self, target_ref: &str) -> Result<()> {
1640 let target_commit = self.resolve_reference(target_ref)?;
1641
1642 self.repo
1643 .reset(target_commit.as_object(), git2::ResetType::Soft, None)
1644 .map_err(CascadeError::Git)?;
1645
1646 Ok(())
1647 }
1648
1649 pub fn find_branch_containing_commit(&self, commit_hash: &str) -> Result<String> {
1651 let oid = Oid::from_str(commit_hash).map_err(|e| {
1652 CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
1653 })?;
1654
1655 let branches = self
1657 .repo
1658 .branches(Some(git2::BranchType::Local))
1659 .map_err(CascadeError::Git)?;
1660
1661 for branch_result in branches {
1662 let (branch, _) = branch_result.map_err(CascadeError::Git)?;
1663
1664 if let Some(branch_name) = branch.name().map_err(CascadeError::Git)? {
1665 if let Ok(branch_head) = branch.get().peel_to_commit() {
1667 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
1669 revwalk.push(branch_head.id()).map_err(CascadeError::Git)?;
1670
1671 for commit_oid in revwalk {
1672 let commit_oid = commit_oid.map_err(CascadeError::Git)?;
1673 if commit_oid == oid {
1674 return Ok(branch_name.to_string());
1675 }
1676 }
1677 }
1678 }
1679 }
1680
1681 Err(CascadeError::branch(format!(
1683 "Commit {commit_hash} not found in any local branch"
1684 )))
1685 }
1686
1687 pub async fn fetch_async(&self) -> Result<()> {
1691 let repo_path = self.path.clone();
1692 crate::utils::async_ops::run_git_operation(move || {
1693 let repo = GitRepository::open(&repo_path)?;
1694 repo.fetch()
1695 })
1696 .await
1697 }
1698
1699 pub async fn pull_async(&self, branch: &str) -> Result<()> {
1701 let repo_path = self.path.clone();
1702 let branch_name = branch.to_string();
1703 crate::utils::async_ops::run_git_operation(move || {
1704 let repo = GitRepository::open(&repo_path)?;
1705 repo.pull(&branch_name)
1706 })
1707 .await
1708 }
1709
1710 pub async fn push_branch_async(&self, branch_name: &str) -> Result<()> {
1712 let repo_path = self.path.clone();
1713 let branch = branch_name.to_string();
1714 crate::utils::async_ops::run_git_operation(move || {
1715 let repo = GitRepository::open(&repo_path)?;
1716 repo.push(&branch)
1717 })
1718 .await
1719 }
1720
1721 pub async fn cherry_pick_commit_async(&self, commit_hash: &str) -> Result<String> {
1723 let repo_path = self.path.clone();
1724 let hash = commit_hash.to_string();
1725 crate::utils::async_ops::run_git_operation(move || {
1726 let repo = GitRepository::open(&repo_path)?;
1727 repo.cherry_pick(&hash)
1728 })
1729 .await
1730 }
1731
1732 pub async fn get_commit_hashes_between_async(
1734 &self,
1735 from: &str,
1736 to: &str,
1737 ) -> Result<Vec<String>> {
1738 let repo_path = self.path.clone();
1739 let from_str = from.to_string();
1740 let to_str = to.to_string();
1741 crate::utils::async_ops::run_git_operation(move || {
1742 let repo = GitRepository::open(&repo_path)?;
1743 let commits = repo.get_commits_between(&from_str, &to_str)?;
1744 Ok(commits.into_iter().map(|c| c.id().to_string()).collect())
1745 })
1746 .await
1747 }
1748
1749 pub fn reset_branch_to_commit(&self, branch_name: &str, commit_hash: &str) -> Result<()> {
1751 info!(
1752 "Resetting branch '{}' to commit {}",
1753 branch_name,
1754 &commit_hash[..8]
1755 );
1756
1757 let target_oid = git2::Oid::from_str(commit_hash).map_err(|e| {
1759 CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
1760 })?;
1761
1762 let _target_commit = self.repo.find_commit(target_oid).map_err(|e| {
1763 CascadeError::branch(format!("Could not find commit '{commit_hash}': {e}"))
1764 })?;
1765
1766 let _branch = self
1768 .repo
1769 .find_branch(branch_name, git2::BranchType::Local)
1770 .map_err(|e| {
1771 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
1772 })?;
1773
1774 let branch_ref_name = format!("refs/heads/{branch_name}");
1776 self.repo
1777 .reference(
1778 &branch_ref_name,
1779 target_oid,
1780 true,
1781 &format!("Reset {branch_name} to {commit_hash}"),
1782 )
1783 .map_err(|e| {
1784 CascadeError::branch(format!(
1785 "Could not reset branch '{branch_name}' to commit '{commit_hash}': {e}"
1786 ))
1787 })?;
1788
1789 tracing::info!(
1790 "Successfully reset branch '{}' to commit {}",
1791 branch_name,
1792 &commit_hash[..8]
1793 );
1794 Ok(())
1795 }
1796}
1797
1798#[cfg(test)]
1799mod tests {
1800 use super::*;
1801 use std::process::Command;
1802 use tempfile::TempDir;
1803
1804 fn create_test_repo() -> (TempDir, PathBuf) {
1805 let temp_dir = TempDir::new().unwrap();
1806 let repo_path = temp_dir.path().to_path_buf();
1807
1808 Command::new("git")
1810 .args(["init"])
1811 .current_dir(&repo_path)
1812 .output()
1813 .unwrap();
1814 Command::new("git")
1815 .args(["config", "user.name", "Test"])
1816 .current_dir(&repo_path)
1817 .output()
1818 .unwrap();
1819 Command::new("git")
1820 .args(["config", "user.email", "test@test.com"])
1821 .current_dir(&repo_path)
1822 .output()
1823 .unwrap();
1824
1825 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
1827 Command::new("git")
1828 .args(["add", "."])
1829 .current_dir(&repo_path)
1830 .output()
1831 .unwrap();
1832 Command::new("git")
1833 .args(["commit", "-m", "Initial commit"])
1834 .current_dir(&repo_path)
1835 .output()
1836 .unwrap();
1837
1838 (temp_dir, repo_path)
1839 }
1840
1841 fn create_commit(repo_path: &PathBuf, message: &str, filename: &str) {
1842 let file_path = repo_path.join(filename);
1843 std::fs::write(&file_path, format!("Content for {filename}\n")).unwrap();
1844
1845 Command::new("git")
1846 .args(["add", filename])
1847 .current_dir(repo_path)
1848 .output()
1849 .unwrap();
1850 Command::new("git")
1851 .args(["commit", "-m", message])
1852 .current_dir(repo_path)
1853 .output()
1854 .unwrap();
1855 }
1856
1857 #[test]
1858 fn test_repository_info() {
1859 let (_temp_dir, repo_path) = create_test_repo();
1860 let repo = GitRepository::open(&repo_path).unwrap();
1861
1862 let info = repo.get_info().unwrap();
1863 assert!(!info.is_dirty); assert!(
1865 info.head_branch == Some("master".to_string())
1866 || info.head_branch == Some("main".to_string()),
1867 "Expected default branch to be 'master' or 'main', got {:?}",
1868 info.head_branch
1869 );
1870 assert!(info.head_commit.is_some()); assert!(info.untracked_files.is_empty()); }
1873
1874 #[test]
1875 fn test_force_push_branch_basic() {
1876 let (_temp_dir, repo_path) = create_test_repo();
1877 let repo = GitRepository::open(&repo_path).unwrap();
1878
1879 let default_branch = repo.get_current_branch().unwrap();
1881
1882 create_commit(&repo_path, "Feature commit 1", "feature1.rs");
1884 Command::new("git")
1885 .args(["checkout", "-b", "source-branch"])
1886 .current_dir(&repo_path)
1887 .output()
1888 .unwrap();
1889 create_commit(&repo_path, "Feature commit 2", "feature2.rs");
1890
1891 Command::new("git")
1893 .args(["checkout", &default_branch])
1894 .current_dir(&repo_path)
1895 .output()
1896 .unwrap();
1897 Command::new("git")
1898 .args(["checkout", "-b", "target-branch"])
1899 .current_dir(&repo_path)
1900 .output()
1901 .unwrap();
1902 create_commit(&repo_path, "Target commit", "target.rs");
1903
1904 let result = repo.force_push_branch("target-branch", "source-branch");
1906
1907 assert!(result.is_ok() || result.is_err()); }
1911
1912 #[test]
1913 fn test_force_push_branch_nonexistent_branches() {
1914 let (_temp_dir, repo_path) = create_test_repo();
1915 let repo = GitRepository::open(&repo_path).unwrap();
1916
1917 let default_branch = repo.get_current_branch().unwrap();
1919
1920 let result = repo.force_push_branch("target", "nonexistent-source");
1922 assert!(result.is_err());
1923
1924 let result = repo.force_push_branch("nonexistent-target", &default_branch);
1926 assert!(result.is_err());
1927 }
1928
1929 #[test]
1930 fn test_force_push_workflow_simulation() {
1931 let (_temp_dir, repo_path) = create_test_repo();
1932 let repo = GitRepository::open(&repo_path).unwrap();
1933
1934 Command::new("git")
1937 .args(["checkout", "-b", "feature-auth"])
1938 .current_dir(&repo_path)
1939 .output()
1940 .unwrap();
1941 create_commit(&repo_path, "Add authentication", "auth.rs");
1942
1943 Command::new("git")
1945 .args(["checkout", "-b", "feature-auth-v2"])
1946 .current_dir(&repo_path)
1947 .output()
1948 .unwrap();
1949 create_commit(&repo_path, "Fix auth validation", "auth.rs");
1950
1951 let result = repo.force_push_branch("feature-auth", "feature-auth-v2");
1953
1954 match result {
1956 Ok(_) => {
1957 Command::new("git")
1959 .args(["checkout", "feature-auth"])
1960 .current_dir(&repo_path)
1961 .output()
1962 .unwrap();
1963 let log_output = Command::new("git")
1964 .args(["log", "--oneline", "-2"])
1965 .current_dir(&repo_path)
1966 .output()
1967 .unwrap();
1968 let log_str = String::from_utf8_lossy(&log_output.stdout);
1969 assert!(
1970 log_str.contains("Fix auth validation")
1971 || log_str.contains("Add authentication")
1972 );
1973 }
1974 Err(_) => {
1975 }
1978 }
1979 }
1980
1981 #[test]
1982 fn test_branch_operations() {
1983 let (_temp_dir, repo_path) = create_test_repo();
1984 let repo = GitRepository::open(&repo_path).unwrap();
1985
1986 let current = repo.get_current_branch().unwrap();
1988 assert!(
1989 current == "master" || current == "main",
1990 "Expected default branch to be 'master' or 'main', got '{current}'"
1991 );
1992
1993 Command::new("git")
1995 .args(["checkout", "-b", "test-branch"])
1996 .current_dir(&repo_path)
1997 .output()
1998 .unwrap();
1999 let current = repo.get_current_branch().unwrap();
2000 assert_eq!(current, "test-branch");
2001 }
2002
2003 #[test]
2004 fn test_commit_operations() {
2005 let (_temp_dir, repo_path) = create_test_repo();
2006 let repo = GitRepository::open(&repo_path).unwrap();
2007
2008 let head = repo.get_head_commit().unwrap();
2010 assert_eq!(head.message().unwrap().trim(), "Initial commit");
2011
2012 let hash = head.id().to_string();
2014 let same_commit = repo.get_commit(&hash).unwrap();
2015 assert_eq!(head.id(), same_commit.id());
2016 }
2017
2018 #[test]
2019 fn test_checkout_safety_clean_repo() {
2020 let (_temp_dir, repo_path) = create_test_repo();
2021 let repo = GitRepository::open(&repo_path).unwrap();
2022
2023 create_commit(&repo_path, "Second commit", "test.txt");
2025 Command::new("git")
2026 .args(["checkout", "-b", "test-branch"])
2027 .current_dir(&repo_path)
2028 .output()
2029 .unwrap();
2030
2031 let safety_result = repo.check_checkout_safety("main");
2033 assert!(safety_result.is_ok());
2034 assert!(safety_result.unwrap().is_none()); }
2036
2037 #[test]
2038 fn test_checkout_safety_with_modified_files() {
2039 let (_temp_dir, repo_path) = create_test_repo();
2040 let repo = GitRepository::open(&repo_path).unwrap();
2041
2042 Command::new("git")
2044 .args(["checkout", "-b", "test-branch"])
2045 .current_dir(&repo_path)
2046 .output()
2047 .unwrap();
2048
2049 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2051
2052 let safety_result = repo.check_checkout_safety("main");
2054 assert!(safety_result.is_ok());
2055 let safety_info = safety_result.unwrap();
2056 assert!(safety_info.is_some());
2057
2058 let info = safety_info.unwrap();
2059 assert!(!info.modified_files.is_empty());
2060 assert!(info.modified_files.contains(&"README.md".to_string()));
2061 }
2062
2063 #[test]
2064 fn test_unsafe_checkout_methods() {
2065 let (_temp_dir, repo_path) = create_test_repo();
2066 let repo = GitRepository::open(&repo_path).unwrap();
2067
2068 create_commit(&repo_path, "Second commit", "test.txt");
2070 Command::new("git")
2071 .args(["checkout", "-b", "test-branch"])
2072 .current_dir(&repo_path)
2073 .output()
2074 .unwrap();
2075
2076 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2078
2079 let _result = repo.checkout_branch_unsafe("master");
2081 let head_commit = repo.get_head_commit().unwrap();
2086 let commit_hash = head_commit.id().to_string();
2087 let _result = repo.checkout_commit_unsafe(&commit_hash);
2088 }
2090
2091 #[test]
2092 fn test_get_modified_files() {
2093 let (_temp_dir, repo_path) = create_test_repo();
2094 let repo = GitRepository::open(&repo_path).unwrap();
2095
2096 let modified = repo.get_modified_files().unwrap();
2098 assert!(modified.is_empty());
2099
2100 std::fs::write(repo_path.join("README.md"), "Modified content").unwrap();
2102
2103 let modified = repo.get_modified_files().unwrap();
2105 assert_eq!(modified.len(), 1);
2106 assert!(modified.contains(&"README.md".to_string()));
2107 }
2108
2109 #[test]
2110 fn test_get_staged_files() {
2111 let (_temp_dir, repo_path) = create_test_repo();
2112 let repo = GitRepository::open(&repo_path).unwrap();
2113
2114 let staged = repo.get_staged_files().unwrap();
2116 assert!(staged.is_empty());
2117
2118 std::fs::write(repo_path.join("staged.txt"), "Staged content").unwrap();
2120 Command::new("git")
2121 .args(["add", "staged.txt"])
2122 .current_dir(&repo_path)
2123 .output()
2124 .unwrap();
2125
2126 let staged = repo.get_staged_files().unwrap();
2128 assert_eq!(staged.len(), 1);
2129 assert!(staged.contains(&"staged.txt".to_string()));
2130 }
2131
2132 #[test]
2133 fn test_create_stash_fallback() {
2134 let (_temp_dir, repo_path) = create_test_repo();
2135 let repo = GitRepository::open(&repo_path).unwrap();
2136
2137 let result = repo.create_stash("test stash");
2139 assert!(result.is_err());
2140 let error_msg = result.unwrap_err().to_string();
2141 assert!(error_msg.contains("git stash push"));
2142 }
2143
2144 #[test]
2145 fn test_delete_branch_unsafe() {
2146 let (_temp_dir, repo_path) = create_test_repo();
2147 let repo = GitRepository::open(&repo_path).unwrap();
2148
2149 create_commit(&repo_path, "Second commit", "test.txt");
2151 Command::new("git")
2152 .args(["checkout", "-b", "test-branch"])
2153 .current_dir(&repo_path)
2154 .output()
2155 .unwrap();
2156
2157 create_commit(&repo_path, "Branch-specific commit", "branch.txt");
2159
2160 Command::new("git")
2162 .args(["checkout", "master"])
2163 .current_dir(&repo_path)
2164 .output()
2165 .unwrap();
2166
2167 let result = repo.delete_branch_unsafe("test-branch");
2170 let _ = result; }
2174
2175 #[test]
2176 fn test_force_push_unsafe() {
2177 let (_temp_dir, repo_path) = create_test_repo();
2178 let repo = GitRepository::open(&repo_path).unwrap();
2179
2180 create_commit(&repo_path, "Second commit", "test.txt");
2182 Command::new("git")
2183 .args(["checkout", "-b", "test-branch"])
2184 .current_dir(&repo_path)
2185 .output()
2186 .unwrap();
2187
2188 let _result = repo.force_push_branch_unsafe("test-branch", "test-branch");
2191 }
2193}