1use crate::errors::{CascadeError, Result};
2use git2::{Oid, Repository, Signature};
3use std::path::{Path, PathBuf};
4use tracing::info;
5
6#[derive(Debug, Clone)]
8pub struct RepositoryInfo {
9 pub path: PathBuf,
10 pub head_branch: Option<String>,
11 pub head_commit: Option<String>,
12 pub is_dirty: bool,
13 pub untracked_files: Vec<String>,
14}
15
16pub struct GitRepository {
18 repo: Repository,
19 path: PathBuf,
20}
21
22impl GitRepository {
23 pub fn open(path: &Path) -> Result<Self> {
25 let repo = Repository::discover(path)
26 .map_err(|e| CascadeError::config(format!("Not a git repository: {e}")))?;
27
28 let workdir = repo
29 .workdir()
30 .ok_or_else(|| CascadeError::config("Repository has no working directory"))?
31 .to_path_buf();
32
33 Ok(Self {
34 repo,
35 path: workdir,
36 })
37 }
38
39 pub fn get_info(&self) -> Result<RepositoryInfo> {
41 let head_branch = self.get_current_branch().ok();
42 let head_commit = self.get_head_commit_hash().ok();
43 let is_dirty = self.is_dirty()?;
44 let untracked_files = self.get_untracked_files()?;
45
46 Ok(RepositoryInfo {
47 path: self.path.clone(),
48 head_branch,
49 head_commit,
50 is_dirty,
51 untracked_files,
52 })
53 }
54
55 pub fn get_current_branch(&self) -> Result<String> {
57 let head = self
58 .repo
59 .head()
60 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
61
62 if let Some(name) = head.shorthand() {
63 Ok(name.to_string())
64 } else {
65 let commit = head
67 .peel_to_commit()
68 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))?;
69 Ok(format!("HEAD@{}", commit.id()))
70 }
71 }
72
73 pub fn get_head_commit_hash(&self) -> Result<String> {
75 let head = self
76 .repo
77 .head()
78 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
79
80 let commit = head
81 .peel_to_commit()
82 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))?;
83
84 Ok(commit.id().to_string())
85 }
86
87 pub fn is_dirty(&self) -> Result<bool> {
89 let statuses = self.repo.statuses(None).map_err(CascadeError::Git)?;
90
91 for status in statuses.iter() {
92 let flags = status.status();
93
94 if flags.intersects(
96 git2::Status::INDEX_MODIFIED
97 | git2::Status::INDEX_NEW
98 | git2::Status::INDEX_DELETED
99 | git2::Status::WT_MODIFIED
100 | git2::Status::WT_NEW
101 | git2::Status::WT_DELETED,
102 ) {
103 return Ok(true);
104 }
105 }
106
107 Ok(false)
108 }
109
110 pub fn get_untracked_files(&self) -> Result<Vec<String>> {
112 let statuses = self.repo.statuses(None).map_err(CascadeError::Git)?;
113
114 let mut untracked = Vec::new();
115 for status in statuses.iter() {
116 if status.status().contains(git2::Status::WT_NEW) {
117 if let Some(path) = status.path() {
118 untracked.push(path.to_string());
119 }
120 }
121 }
122
123 Ok(untracked)
124 }
125
126 pub fn create_branch(&self, name: &str, target: Option<&str>) -> Result<()> {
128 let target_commit = if let Some(target) = target {
129 let target_obj = self.repo.revparse_single(target).map_err(|e| {
131 CascadeError::branch(format!("Could not find target '{target}': {e}"))
132 })?;
133 target_obj.peel_to_commit().map_err(|e| {
134 CascadeError::branch(format!("Target '{target}' is not a commit: {e}"))
135 })?
136 } else {
137 let head = self
139 .repo
140 .head()
141 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
142 head.peel_to_commit()
143 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))?
144 };
145
146 self.repo
147 .branch(name, &target_commit, false)
148 .map_err(|e| CascadeError::branch(format!("Could not create branch '{name}': {e}")))?;
149
150 tracing::info!("Created branch '{}'", name);
151 Ok(())
152 }
153
154 pub fn checkout_branch(&self, name: &str) -> Result<()> {
156 let branch = self
158 .repo
159 .find_branch(name, git2::BranchType::Local)
160 .map_err(|e| CascadeError::branch(format!("Could not find branch '{name}': {e}")))?;
161
162 let branch_ref = branch.get();
163 let tree = branch_ref.peel_to_tree().map_err(|e| {
164 CascadeError::branch(format!("Could not get tree for branch '{name}': {e}"))
165 })?;
166
167 self.repo
169 .checkout_tree(tree.as_object(), None)
170 .map_err(|e| {
171 CascadeError::branch(format!("Could not checkout branch '{name}': {e}"))
172 })?;
173
174 self.repo
176 .set_head(&format!("refs/heads/{name}"))
177 .map_err(|e| CascadeError::branch(format!("Could not update HEAD to '{name}': {e}")))?;
178
179 tracing::info!("Switched to branch '{}'", name);
180 Ok(())
181 }
182
183 pub fn checkout_commit(&self, commit_hash: &str) -> Result<()> {
185 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
186
187 let commit = self.repo.find_commit(oid).map_err(|e| {
188 CascadeError::branch(format!("Could not find commit '{commit_hash}': {e}"))
189 })?;
190
191 let tree = commit.tree().map_err(|e| {
192 CascadeError::branch(format!(
193 "Could not get tree for commit '{commit_hash}': {e}"
194 ))
195 })?;
196
197 self.repo
199 .checkout_tree(tree.as_object(), None)
200 .map_err(|e| {
201 CascadeError::branch(format!("Could not checkout commit '{commit_hash}': {e}"))
202 })?;
203
204 self.repo.set_head_detached(oid).map_err(|e| {
206 CascadeError::branch(format!(
207 "Could not update HEAD to commit '{commit_hash}': {e}"
208 ))
209 })?;
210
211 tracing::info!("Checked out commit '{}' (detached HEAD)", commit_hash);
212 Ok(())
213 }
214
215 pub fn branch_exists(&self, name: &str) -> bool {
217 self.repo.find_branch(name, git2::BranchType::Local).is_ok()
218 }
219
220 pub fn list_branches(&self) -> Result<Vec<String>> {
222 let branches = self
223 .repo
224 .branches(Some(git2::BranchType::Local))
225 .map_err(CascadeError::Git)?;
226
227 let mut branch_names = Vec::new();
228 for branch in branches {
229 let (branch, _) = branch.map_err(CascadeError::Git)?;
230 if let Some(name) = branch.name().map_err(CascadeError::Git)? {
231 branch_names.push(name.to_string());
232 }
233 }
234
235 Ok(branch_names)
236 }
237
238 pub fn commit(&self, message: &str) -> Result<String> {
240 let signature = self.get_signature()?;
241 let tree_id = self.get_index_tree()?;
242 let tree = self.repo.find_tree(tree_id).map_err(CascadeError::Git)?;
243
244 let head = self.repo.head().map_err(CascadeError::Git)?;
246 let parent_commit = head.peel_to_commit().map_err(CascadeError::Git)?;
247
248 let commit_id = self
249 .repo
250 .commit(
251 Some("HEAD"),
252 &signature,
253 &signature,
254 message,
255 &tree,
256 &[&parent_commit],
257 )
258 .map_err(CascadeError::Git)?;
259
260 tracing::info!("Created commit: {} - {}", commit_id, message);
261 Ok(commit_id.to_string())
262 }
263
264 pub fn stage_all(&self) -> Result<()> {
266 let mut index = self.repo.index().map_err(CascadeError::Git)?;
267
268 index
269 .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
270 .map_err(CascadeError::Git)?;
271
272 index.write().map_err(CascadeError::Git)?;
273
274 tracing::debug!("Staged all changes");
275 Ok(())
276 }
277
278 pub fn path(&self) -> &Path {
280 &self.path
281 }
282
283 pub fn commit_exists(&self, commit_hash: &str) -> Result<bool> {
285 match Oid::from_str(commit_hash) {
286 Ok(oid) => match self.repo.find_commit(oid) {
287 Ok(_) => Ok(true),
288 Err(_) => Ok(false),
289 },
290 Err(_) => Ok(false),
291 }
292 }
293
294 pub fn get_head_commit(&self) -> Result<git2::Commit<'_>> {
296 let head = self
297 .repo
298 .head()
299 .map_err(|e| CascadeError::branch(format!("Could not get HEAD: {e}")))?;
300 head.peel_to_commit()
301 .map_err(|e| CascadeError::branch(format!("Could not get HEAD commit: {e}")))
302 }
303
304 pub fn get_commit(&self, commit_hash: &str) -> Result<git2::Commit<'_>> {
306 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
307
308 self.repo.find_commit(oid).map_err(CascadeError::Git)
309 }
310
311 pub fn get_branch_head(&self, branch_name: &str) -> Result<String> {
313 let branch = self
314 .repo
315 .find_branch(branch_name, git2::BranchType::Local)
316 .map_err(|e| {
317 CascadeError::branch(format!("Could not find branch '{branch_name}': {e}"))
318 })?;
319
320 let commit = branch.get().peel_to_commit().map_err(|e| {
321 CascadeError::branch(format!(
322 "Could not get commit for branch '{branch_name}': {e}"
323 ))
324 })?;
325
326 Ok(commit.id().to_string())
327 }
328
329 fn get_signature(&self) -> Result<Signature<'_>> {
331 if let Ok(config) = self.repo.config() {
333 if let (Ok(name), Ok(email)) = (
334 config.get_string("user.name"),
335 config.get_string("user.email"),
336 ) {
337 return Signature::now(&name, &email).map_err(CascadeError::Git);
338 }
339 }
340
341 Signature::now("Cascade CLI", "cascade@example.com").map_err(CascadeError::Git)
343 }
344
345 fn get_index_tree(&self) -> Result<Oid> {
347 let mut index = self.repo.index().map_err(CascadeError::Git)?;
348
349 index.write_tree().map_err(CascadeError::Git)
350 }
351
352 pub fn get_status(&self) -> Result<git2::Statuses<'_>> {
354 self.repo.statuses(None).map_err(CascadeError::Git)
355 }
356
357 pub fn get_remote_url(&self, name: &str) -> Result<String> {
359 let remote = self.repo.find_remote(name).map_err(CascadeError::Git)?;
360
361 let url = remote.url().ok_or_else(|| {
362 CascadeError::Git(git2::Error::from_str("Remote URL is not valid UTF-8"))
363 })?;
364
365 Ok(url.to_string())
366 }
367
368 pub fn cherry_pick(&self, commit_hash: &str) -> Result<String> {
370 tracing::debug!("Cherry-picking commit {}", commit_hash);
371
372 let oid = Oid::from_str(commit_hash).map_err(CascadeError::Git)?;
373 let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
374
375 let commit_tree = commit.tree().map_err(CascadeError::Git)?;
377
378 let parent_commit = if commit.parent_count() > 0 {
380 commit.parent(0).map_err(CascadeError::Git)?
381 } else {
382 let empty_tree_oid = self.repo.treebuilder(None)?.write()?;
384 let empty_tree = self.repo.find_tree(empty_tree_oid)?;
385 let sig = self.get_signature()?;
386 return self
387 .repo
388 .commit(
389 Some("HEAD"),
390 &sig,
391 &sig,
392 commit.message().unwrap_or("Cherry-picked commit"),
393 &empty_tree,
394 &[],
395 )
396 .map(|oid| oid.to_string())
397 .map_err(CascadeError::Git);
398 };
399
400 let parent_tree = parent_commit.tree().map_err(CascadeError::Git)?;
401
402 let head_commit = self.get_head_commit()?;
404 let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
405
406 let mut index = self
408 .repo
409 .merge_trees(&parent_tree, &head_tree, &commit_tree, None)
410 .map_err(CascadeError::Git)?;
411
412 if index.has_conflicts() {
414 return Err(CascadeError::branch(format!(
415 "Cherry-pick of {commit_hash} has conflicts that need manual resolution"
416 )));
417 }
418
419 let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
421 let merged_tree = self
422 .repo
423 .find_tree(merged_tree_oid)
424 .map_err(CascadeError::Git)?;
425
426 let signature = self.get_signature()?;
428 let message = format!("Cherry-pick: {}", commit.message().unwrap_or(""));
429
430 let new_commit_oid = self
431 .repo
432 .commit(
433 Some("HEAD"),
434 &signature,
435 &signature,
436 &message,
437 &merged_tree,
438 &[&head_commit],
439 )
440 .map_err(CascadeError::Git)?;
441
442 tracing::info!("Cherry-picked {} -> {}", commit_hash, new_commit_oid);
443 Ok(new_commit_oid.to_string())
444 }
445
446 pub fn has_conflicts(&self) -> Result<bool> {
448 let index = self.repo.index().map_err(CascadeError::Git)?;
449 Ok(index.has_conflicts())
450 }
451
452 pub fn get_conflicted_files(&self) -> Result<Vec<String>> {
454 let index = self.repo.index().map_err(CascadeError::Git)?;
455
456 let mut conflicts = Vec::new();
457
458 let conflict_iter = index.conflicts().map_err(CascadeError::Git)?;
460
461 for conflict in conflict_iter {
462 let conflict = conflict.map_err(CascadeError::Git)?;
463 if let Some(our) = conflict.our {
464 if let Ok(path) = std::str::from_utf8(&our.path) {
465 conflicts.push(path.to_string());
466 }
467 } else if let Some(their) = conflict.their {
468 if let Ok(path) = std::str::from_utf8(&their.path) {
469 conflicts.push(path.to_string());
470 }
471 }
472 }
473
474 Ok(conflicts)
475 }
476
477 pub fn fetch(&self) -> Result<()> {
479 tracing::info!("Fetching from origin");
480
481 let mut remote = self
482 .repo
483 .find_remote("origin")
484 .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
485
486 remote
488 .fetch::<&str>(&[], None, None)
489 .map_err(CascadeError::Git)?;
490
491 tracing::debug!("Fetch completed successfully");
492 Ok(())
493 }
494
495 pub fn pull(&self, branch: &str) -> Result<()> {
497 tracing::info!("Pulling branch: {}", branch);
498
499 self.fetch()?;
501
502 let remote_branch_name = format!("origin/{branch}");
504 let remote_oid = self
505 .repo
506 .refname_to_id(&format!("refs/remotes/{remote_branch_name}"))
507 .map_err(|e| {
508 CascadeError::branch(format!("Remote branch {remote_branch_name} not found: {e}"))
509 })?;
510
511 let remote_commit = self
512 .repo
513 .find_commit(remote_oid)
514 .map_err(CascadeError::Git)?;
515
516 let head_commit = self.get_head_commit()?;
518
519 if head_commit.id() == remote_commit.id() {
521 tracing::debug!("Already up to date");
522 return Ok(());
523 }
524
525 let head_tree = head_commit.tree().map_err(CascadeError::Git)?;
527 let remote_tree = remote_commit.tree().map_err(CascadeError::Git)?;
528
529 let merge_base_oid = self
531 .repo
532 .merge_base(head_commit.id(), remote_commit.id())
533 .map_err(CascadeError::Git)?;
534 let merge_base_commit = self
535 .repo
536 .find_commit(merge_base_oid)
537 .map_err(CascadeError::Git)?;
538 let merge_base_tree = merge_base_commit.tree().map_err(CascadeError::Git)?;
539
540 let mut index = self
542 .repo
543 .merge_trees(&merge_base_tree, &head_tree, &remote_tree, None)
544 .map_err(CascadeError::Git)?;
545
546 if index.has_conflicts() {
547 return Err(CascadeError::branch(
548 "Pull has conflicts that need manual resolution".to_string(),
549 ));
550 }
551
552 let merged_tree_oid = index.write_tree_to(&self.repo).map_err(CascadeError::Git)?;
554 let merged_tree = self
555 .repo
556 .find_tree(merged_tree_oid)
557 .map_err(CascadeError::Git)?;
558
559 let signature = self.get_signature()?;
560 let message = format!("Merge branch '{branch}' from origin");
561
562 self.repo
563 .commit(
564 Some("HEAD"),
565 &signature,
566 &signature,
567 &message,
568 &merged_tree,
569 &[&head_commit, &remote_commit],
570 )
571 .map_err(CascadeError::Git)?;
572
573 tracing::info!("Pull completed successfully");
574 Ok(())
575 }
576
577 pub fn push(&self, branch: &str) -> Result<()> {
579 tracing::info!("Pushing branch: {}", branch);
580
581 let mut remote = self
582 .repo
583 .find_remote("origin")
584 .map_err(|e| CascadeError::branch(format!("No remote 'origin' found: {e}")))?;
585
586 let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
587
588 remote.push(&[&refspec], None).map_err(CascadeError::Git)?;
589
590 tracing::info!("Push completed successfully");
591 Ok(())
592 }
593
594 pub fn delete_branch(&self, name: &str) -> Result<()> {
596 tracing::info!("Deleting branch: {}", name);
597
598 let mut branch = self
599 .repo
600 .find_branch(name, git2::BranchType::Local)
601 .map_err(|e| CascadeError::branch(format!("Could not find branch '{name}': {e}")))?;
602
603 branch
604 .delete()
605 .map_err(|e| CascadeError::branch(format!("Could not delete branch '{name}': {e}")))?;
606
607 tracing::info!("Deleted branch '{}'", name);
608 Ok(())
609 }
610
611 pub fn get_commits_between(&self, from: &str, to: &str) -> Result<Vec<git2::Commit<'_>>> {
613 let from_oid = self
614 .repo
615 .refname_to_id(&format!("refs/heads/{from}"))
616 .or_else(|_| Oid::from_str(from))
617 .map_err(|e| CascadeError::branch(format!("Invalid from reference '{from}': {e}")))?;
618
619 let to_oid = self
620 .repo
621 .refname_to_id(&format!("refs/heads/{to}"))
622 .or_else(|_| Oid::from_str(to))
623 .map_err(|e| CascadeError::branch(format!("Invalid to reference '{to}': {e}")))?;
624
625 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
626
627 revwalk.push(to_oid).map_err(CascadeError::Git)?;
628 revwalk.hide(from_oid).map_err(CascadeError::Git)?;
629
630 let mut commits = Vec::new();
631 for oid in revwalk {
632 let oid = oid.map_err(CascadeError::Git)?;
633 let commit = self.repo.find_commit(oid).map_err(CascadeError::Git)?;
634 commits.push(commit);
635 }
636
637 Ok(commits)
638 }
639
640 pub fn force_push_branch(&self, target_branch: &str, source_branch: &str) -> Result<()> {
643 info!(
644 "Force pushing {} content to {} to preserve PR history",
645 source_branch, target_branch
646 );
647
648 let source_ref = self
650 .repo
651 .find_reference(&format!("refs/heads/{source_branch}"))
652 .map_err(|e| {
653 CascadeError::config(format!("Failed to find source branch {source_branch}: {e}"))
654 })?;
655 let source_commit = source_ref.peel_to_commit().map_err(|e| {
656 CascadeError::config(format!(
657 "Failed to get commit for source branch {source_branch}: {e}"
658 ))
659 })?;
660
661 let mut target_ref = self
663 .repo
664 .find_reference(&format!("refs/heads/{target_branch}"))
665 .map_err(|e| {
666 CascadeError::config(format!("Failed to find target branch {target_branch}: {e}"))
667 })?;
668
669 target_ref
670 .set_target(source_commit.id(), "Force push from rebase")
671 .map_err(|e| {
672 CascadeError::config(format!(
673 "Failed to update target branch {target_branch}: {e}"
674 ))
675 })?;
676
677 let mut remote = self
679 .repo
680 .find_remote("origin")
681 .map_err(|e| CascadeError::config(format!("Failed to find origin remote: {e}")))?;
682
683 let refspec = format!("+refs/heads/{target_branch}:refs/heads/{target_branch}");
684
685 let mut callbacks = git2::RemoteCallbacks::new();
687
688 callbacks.credentials(|_url, username_from_url, _allowed_types| {
690 if let Some(username) = username_from_url {
691 git2::Cred::ssh_key_from_agent(username)
693 } else {
694 git2::Cred::default()
696 }
697 });
698
699 let mut push_options = git2::PushOptions::new();
701 push_options.remote_callbacks(callbacks);
702
703 remote
704 .push(&[&refspec], Some(&mut push_options))
705 .map_err(|e| {
706 CascadeError::config(format!("Failed to force push {target_branch}: {e}"))
707 })?;
708
709 info!(
710 "✅ Successfully force pushed {} to preserve PR history",
711 target_branch
712 );
713 Ok(())
714 }
715
716 pub fn resolve_reference(&self, reference: &str) -> Result<git2::Commit<'_>> {
718 if let Ok(oid) = Oid::from_str(reference) {
720 if let Ok(commit) = self.repo.find_commit(oid) {
721 return Ok(commit);
722 }
723 }
724
725 let obj = self.repo.revparse_single(reference).map_err(|e| {
727 CascadeError::branch(format!("Could not resolve reference '{reference}': {e}"))
728 })?;
729
730 obj.peel_to_commit().map_err(|e| {
731 CascadeError::branch(format!(
732 "Reference '{reference}' does not point to a commit: {e}"
733 ))
734 })
735 }
736
737 pub fn reset_soft(&self, target_ref: &str) -> Result<()> {
739 let target_commit = self.resolve_reference(target_ref)?;
740
741 self.repo
742 .reset(target_commit.as_object(), git2::ResetType::Soft, None)
743 .map_err(CascadeError::Git)?;
744
745 Ok(())
746 }
747
748 pub fn find_branch_containing_commit(&self, commit_hash: &str) -> Result<String> {
750 let oid = Oid::from_str(commit_hash).map_err(|e| {
751 CascadeError::branch(format!("Invalid commit hash '{commit_hash}': {e}"))
752 })?;
753
754 let branches = self
756 .repo
757 .branches(Some(git2::BranchType::Local))
758 .map_err(CascadeError::Git)?;
759
760 for branch_result in branches {
761 let (branch, _) = branch_result.map_err(CascadeError::Git)?;
762
763 if let Some(branch_name) = branch.name().map_err(CascadeError::Git)? {
764 if let Ok(branch_head) = branch.get().peel_to_commit() {
766 let mut revwalk = self.repo.revwalk().map_err(CascadeError::Git)?;
768 revwalk.push(branch_head.id()).map_err(CascadeError::Git)?;
769
770 for commit_oid in revwalk {
771 let commit_oid = commit_oid.map_err(CascadeError::Git)?;
772 if commit_oid == oid {
773 return Ok(branch_name.to_string());
774 }
775 }
776 }
777 }
778 }
779
780 Err(CascadeError::branch(format!(
782 "Commit {commit_hash} not found in any local branch"
783 )))
784 }
785}
786
787#[cfg(test)]
788mod tests {
789 use super::*;
790 use std::process::Command;
791 use tempfile::TempDir;
792
793 fn create_test_repo() -> (TempDir, PathBuf) {
794 let temp_dir = TempDir::new().unwrap();
795 let repo_path = temp_dir.path().to_path_buf();
796
797 Command::new("git")
799 .args(["init"])
800 .current_dir(&repo_path)
801 .output()
802 .unwrap();
803 Command::new("git")
804 .args(["config", "user.name", "Test"])
805 .current_dir(&repo_path)
806 .output()
807 .unwrap();
808 Command::new("git")
809 .args(["config", "user.email", "test@test.com"])
810 .current_dir(&repo_path)
811 .output()
812 .unwrap();
813
814 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
816 Command::new("git")
817 .args(["add", "."])
818 .current_dir(&repo_path)
819 .output()
820 .unwrap();
821 Command::new("git")
822 .args(["commit", "-m", "Initial commit"])
823 .current_dir(&repo_path)
824 .output()
825 .unwrap();
826
827 (temp_dir, repo_path)
828 }
829
830 fn create_commit(repo_path: &PathBuf, message: &str, filename: &str) {
831 let file_path = repo_path.join(filename);
832 std::fs::write(&file_path, format!("Content for {filename}\n")).unwrap();
833
834 Command::new("git")
835 .args(["add", filename])
836 .current_dir(repo_path)
837 .output()
838 .unwrap();
839 Command::new("git")
840 .args(["commit", "-m", message])
841 .current_dir(repo_path)
842 .output()
843 .unwrap();
844 }
845
846 #[test]
847 fn test_repository_info() {
848 let (_temp_dir, repo_path) = create_test_repo();
849 let repo = GitRepository::open(&repo_path).unwrap();
850
851 let info = repo.get_info().unwrap();
852 assert!(!info.is_dirty); assert!(
854 info.head_branch == Some("master".to_string())
855 || info.head_branch == Some("main".to_string()),
856 "Expected default branch to be 'master' or 'main', got {:?}",
857 info.head_branch
858 );
859 assert!(info.head_commit.is_some()); assert!(info.untracked_files.is_empty()); }
862
863 #[test]
864 fn test_force_push_branch_basic() {
865 let (_temp_dir, repo_path) = create_test_repo();
866 let repo = GitRepository::open(&repo_path).unwrap();
867
868 let default_branch = repo.get_current_branch().unwrap();
870
871 create_commit(&repo_path, "Feature commit 1", "feature1.rs");
873 Command::new("git")
874 .args(["checkout", "-b", "source-branch"])
875 .current_dir(&repo_path)
876 .output()
877 .unwrap();
878 create_commit(&repo_path, "Feature commit 2", "feature2.rs");
879
880 Command::new("git")
882 .args(["checkout", &default_branch])
883 .current_dir(&repo_path)
884 .output()
885 .unwrap();
886 Command::new("git")
887 .args(["checkout", "-b", "target-branch"])
888 .current_dir(&repo_path)
889 .output()
890 .unwrap();
891 create_commit(&repo_path, "Target commit", "target.rs");
892
893 let result = repo.force_push_branch("target-branch", "source-branch");
895
896 assert!(result.is_ok() || result.is_err()); }
900
901 #[test]
902 fn test_force_push_branch_nonexistent_branches() {
903 let (_temp_dir, repo_path) = create_test_repo();
904 let repo = GitRepository::open(&repo_path).unwrap();
905
906 let default_branch = repo.get_current_branch().unwrap();
908
909 let result = repo.force_push_branch("target", "nonexistent-source");
911 assert!(result.is_err());
912
913 let result = repo.force_push_branch("nonexistent-target", &default_branch);
915 assert!(result.is_err());
916 }
917
918 #[test]
919 fn test_force_push_workflow_simulation() {
920 let (_temp_dir, repo_path) = create_test_repo();
921 let repo = GitRepository::open(&repo_path).unwrap();
922
923 Command::new("git")
926 .args(["checkout", "-b", "feature-auth"])
927 .current_dir(&repo_path)
928 .output()
929 .unwrap();
930 create_commit(&repo_path, "Add authentication", "auth.rs");
931
932 Command::new("git")
934 .args(["checkout", "-b", "feature-auth-v2"])
935 .current_dir(&repo_path)
936 .output()
937 .unwrap();
938 create_commit(&repo_path, "Fix auth validation", "auth.rs");
939
940 let result = repo.force_push_branch("feature-auth", "feature-auth-v2");
942
943 match result {
945 Ok(_) => {
946 Command::new("git")
948 .args(["checkout", "feature-auth"])
949 .current_dir(&repo_path)
950 .output()
951 .unwrap();
952 let log_output = Command::new("git")
953 .args(["log", "--oneline", "-2"])
954 .current_dir(&repo_path)
955 .output()
956 .unwrap();
957 let log_str = String::from_utf8_lossy(&log_output.stdout);
958 assert!(
959 log_str.contains("Fix auth validation")
960 || log_str.contains("Add authentication")
961 );
962 }
963 Err(_) => {
964 }
967 }
968 }
969
970 #[test]
971 fn test_branch_operations() {
972 let (_temp_dir, repo_path) = create_test_repo();
973 let repo = GitRepository::open(&repo_path).unwrap();
974
975 let current = repo.get_current_branch().unwrap();
977 assert!(
978 current == "master" || current == "main",
979 "Expected default branch to be 'master' or 'main', got '{current}'"
980 );
981
982 Command::new("git")
984 .args(["checkout", "-b", "test-branch"])
985 .current_dir(&repo_path)
986 .output()
987 .unwrap();
988 let current = repo.get_current_branch().unwrap();
989 assert_eq!(current, "test-branch");
990 }
991
992 #[test]
993 fn test_commit_operations() {
994 let (_temp_dir, repo_path) = create_test_repo();
995 let repo = GitRepository::open(&repo_path).unwrap();
996
997 let head = repo.get_head_commit().unwrap();
999 assert_eq!(head.message().unwrap().trim(), "Initial commit");
1000
1001 let hash = head.id().to_string();
1003 let same_commit = repo.get_commit(&hash).unwrap();
1004 assert_eq!(head.id(), same_commit.id());
1005 }
1006}