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