1use super::metadata::RepositoryMetadata;
2use super::{CommitMetadata, Stack, StackEntry, StackMetadata, StackStatus};
3use crate::cli::output::Output;
4use crate::config::{get_repo_config_dir, Settings};
5use crate::errors::{CascadeError, Result};
6use crate::git::GitRepository;
7use chrono::Utc;
8use dialoguer::{theme::ColorfulTheme, Input, Select};
9use std::collections::HashMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12use tracing::{debug, warn};
13use uuid::Uuid;
14
15#[derive(Debug)]
17pub enum BranchModification {
18 Missing {
20 branch: String,
21 entry_id: Uuid,
22 expected_commit: String,
23 },
24 ExtraCommits {
26 branch: String,
27 entry_id: Uuid,
28 expected_commit: String,
29 actual_commit: String,
30 extra_commit_count: usize,
31 extra_commit_messages: Vec<String>,
32 },
33}
34
35pub struct StackManager {
37 repo: GitRepository,
39 repo_path: PathBuf,
41 config_dir: PathBuf,
43 stacks_file: PathBuf,
45 metadata_file: PathBuf,
47 stacks: HashMap<Uuid, Stack>,
49 metadata: RepositoryMetadata,
51}
52
53impl StackManager {
54 pub fn new(repo_path: &Path) -> Result<Self> {
56 let repo = GitRepository::open(repo_path)?;
57 let config_dir = get_repo_config_dir(repo_path)?;
58 let stacks_file = config_dir.join("stacks.json");
59 let metadata_file = config_dir.join("metadata.json");
60
61 let config_file = config_dir.join("config.json");
63 let settings = Settings::load_from_file(&config_file).unwrap_or_default();
64 let configured_default = &settings.git.default_branch;
65
66 let default_base = if repo.branch_exists(configured_default) {
70 configured_default.clone()
72 } else {
73 match repo.detect_main_branch() {
75 Ok(detected) => {
76 detected
78 }
79 Err(_) => {
80 configured_default.clone()
83 }
84 }
85 };
86
87 let mut manager = Self {
88 repo,
89 repo_path: repo_path.to_path_buf(),
90 config_dir,
91 stacks_file,
92 metadata_file,
93 stacks: HashMap::new(),
94 metadata: RepositoryMetadata::new(default_base),
95 };
96
97 manager.load_from_disk()?;
99
100 Ok(manager)
101 }
102
103 pub fn create_stack(
105 &mut self,
106 name: String,
107 base_branch: Option<String>,
108 description: Option<String>,
109 ) -> Result<Uuid> {
110 if self.metadata.find_stack_by_name(&name).is_some() {
112 return Err(CascadeError::config(format!(
113 "Stack '{name}' already exists"
114 )));
115 }
116
117 let base_branch = base_branch.unwrap_or_else(|| {
119 if let Ok(Some(detected_parent)) = self.repo.detect_parent_branch() {
121 detected_parent
122 } else {
123 self.metadata.default_base_branch.clone()
125 }
126 });
127
128 if !self.repo.branch_exists_or_fetch(&base_branch)? {
130 return Err(CascadeError::branch(format!(
131 "Base branch '{base_branch}' does not exist locally or remotely"
132 )));
133 }
134
135 let current_branch = self.repo.get_current_branch().ok();
137
138 let mut stack = Stack::new(name.clone(), base_branch.clone(), description.clone());
140
141 if let Some(ref branch) = current_branch {
143 if branch != &base_branch {
144 stack.working_branch = Some(branch.clone());
145 }
146 }
147
148 let stack_id = stack.id;
149
150 let stack_metadata = StackMetadata::new(stack_id, name, base_branch, description);
152
153 self.stacks.insert(stack_id, stack);
155 self.metadata.add_stack(stack_metadata);
156
157 self.set_active_stack(Some(stack_id))?;
159
160 Ok(stack_id)
161 }
162
163 pub fn get_stack(&self, stack_id: &Uuid) -> Option<&Stack> {
165 self.stacks.get(stack_id)
166 }
167
168 pub fn get_stack_mut(&mut self, stack_id: &Uuid) -> Option<&mut Stack> {
170 self.stacks.get_mut(stack_id)
171 }
172
173 pub fn get_stack_by_name(&self, name: &str) -> Option<&Stack> {
175 if let Some(metadata) = self.metadata.find_stack_by_name(name) {
176 self.stacks.get(&metadata.stack_id)
177 } else {
178 None
179 }
180 }
181
182 pub fn get_stack_by_name_mut(&mut self, name: &str) -> Option<&mut Stack> {
184 if let Some(metadata) = self.metadata.find_stack_by_name(name) {
185 self.stacks.get_mut(&metadata.stack_id)
186 } else {
187 None
188 }
189 }
190
191 pub fn update_stack_working_branch(&mut self, name: &str, branch: String) -> Result<()> {
193 if let Some(stack) = self.get_stack_by_name_mut(name) {
194 stack.working_branch = Some(branch);
195 self.save_to_disk()?;
196 Ok(())
197 } else {
198 Err(CascadeError::config(format!("Stack '{name}' not found")))
199 }
200 }
201
202 pub fn get_active_stack(&self) -> Option<&Stack> {
204 self.metadata
205 .active_stack_id
206 .and_then(|id| self.stacks.get(&id))
207 }
208
209 pub fn get_active_stack_mut(&mut self) -> Option<&mut Stack> {
211 if let Some(id) = self.metadata.active_stack_id {
212 self.stacks.get_mut(&id)
213 } else {
214 None
215 }
216 }
217
218 pub fn set_active_stack(&mut self, stack_id: Option<Uuid>) -> Result<()> {
220 if let Some(id) = stack_id {
222 if !self.stacks.contains_key(&id) {
223 return Err(CascadeError::config(format!(
224 "Stack with ID {id} not found"
225 )));
226 }
227 }
228
229 if self.is_in_edit_mode() {
232 if let Some(edit_info) = self.get_edit_mode_info() {
233 if edit_info.target_stack_id != stack_id {
235 tracing::debug!(
236 "Clearing edit mode when switching from stack {:?} to {:?}",
237 edit_info.target_stack_id,
238 stack_id
239 );
240 self.exit_edit_mode()?;
241 }
242 }
243 }
244
245 for stack in self.stacks.values_mut() {
247 stack.set_active(Some(stack.id) == stack_id);
248 }
249
250 if let Some(id) = stack_id {
252 let current_branch = self.repo.get_current_branch().ok();
253 if let Some(stack_meta) = self.metadata.get_stack_mut(&id) {
254 stack_meta.set_current_branch(current_branch);
255 }
256 }
257
258 self.metadata.set_active_stack(stack_id);
259 self.save_to_disk()?;
260
261 Ok(())
262 }
263
264 pub fn set_active_stack_by_name(&mut self, name: &str) -> Result<()> {
266 if let Some(metadata) = self.metadata.find_stack_by_name(name) {
267 self.set_active_stack(Some(metadata.stack_id))
268 } else {
269 Err(CascadeError::config(format!("Stack '{name}' not found")))
270 }
271 }
272
273 pub fn delete_stack(&mut self, stack_id: &Uuid) -> Result<Stack> {
275 let stack = self
276 .stacks
277 .remove(stack_id)
278 .ok_or_else(|| CascadeError::config(format!("Stack with ID {stack_id} not found")))?;
279
280 self.metadata.remove_stack(stack_id);
282
283 let stack_commits: Vec<String> = self
285 .metadata
286 .commits
287 .values()
288 .filter(|commit| &commit.stack_id == stack_id)
289 .map(|commit| commit.hash.clone())
290 .collect();
291
292 for commit_hash in stack_commits {
293 self.metadata.remove_commit(&commit_hash);
294 }
295
296 if self.metadata.active_stack_id == Some(*stack_id) {
298 let new_active = self.metadata.stacks.keys().next().copied();
299 self.set_active_stack(new_active)?;
300 }
301
302 self.save_to_disk()?;
303
304 Ok(stack)
305 }
306
307 pub fn push_to_stack(
309 &mut self,
310 branch: String,
311 commit_hash: String,
312 message: String,
313 source_branch: String,
314 ) -> Result<Uuid> {
315 let stack_id = self
316 .metadata
317 .active_stack_id
318 .ok_or_else(|| CascadeError::config("No active stack"))?;
319
320 let mut reconciled = false;
323 {
324 let stack = self
325 .stacks
326 .get_mut(&stack_id)
327 .ok_or_else(|| CascadeError::config("Active stack not found"))?;
328
329 if !stack.entries.is_empty() {
330 let mut updates = Vec::new();
332 for entry in &stack.entries {
333 if let Ok(current_commit) = self.repo.get_branch_head(&entry.branch) {
334 if entry.commit_hash != current_commit {
335 debug!(
336 "Reconciling stale metadata for '{}': updating hash from {} to {} (current branch HEAD)",
337 entry.branch,
338 &entry.commit_hash[..8],
339 ¤t_commit[..8]
340 );
341 updates.push((entry.id, current_commit));
342 }
343 }
344 }
345
346 for (entry_id, new_hash) in updates {
348 stack
349 .update_entry_commit_hash(&entry_id, new_hash)
350 .map_err(CascadeError::config)?;
351 reconciled = true;
352 }
353 }
354 } if reconciled {
358 debug!("Saving reconciled metadata before validation");
359 self.save_to_disk()?;
360 }
361
362 let stack = self
364 .stacks
365 .get_mut(&stack_id)
366 .ok_or_else(|| CascadeError::config("Active stack not found"))?;
367
368 if !stack.entries.is_empty() {
369 if let Err(integrity_error) = stack.validate_git_integrity(&self.repo) {
370 return Err(CascadeError::validation(format!(
371 "Git integrity validation failed:\n{}\n\n\
372 Fix the stack integrity issues first using 'ca stack validate {}' for details.",
373 integrity_error, stack.name
374 )));
375 }
376 }
377
378 if !self.repo.commit_exists(&commit_hash)? {
380 return Err(CascadeError::branch(format!(
381 "Commit {commit_hash} does not exist"
382 )));
383 }
384
385 let message_trimmed = message.trim();
388 if let Some(duplicate_entry) = stack
389 .entries
390 .iter()
391 .find(|entry| entry.message.trim() == message_trimmed)
392 {
393 return Err(CascadeError::validation(format!(
394 "Duplicate commit message in stack: \"{}\"\n\n\
395 This message already exists in entry {} (commit: {})\n\n\
396 š” Consider using a more specific message:\n\
397 ⢠Add context: \"{} - add validation\"\n\
398 ⢠Be more specific: \"Fix user authentication timeout bug\"\n\
399 ⢠Or amend the previous commit: git commit --amend",
400 message_trimmed,
401 duplicate_entry.id,
402 &duplicate_entry.commit_hash[..8],
403 message_trimmed
404 )));
405 }
406
407 if stack.entries.is_empty() {
412 let current_branch = self.repo.get_current_branch()?;
413
414 if stack.working_branch.is_none() && current_branch != stack.base_branch {
416 stack.working_branch = Some(current_branch.clone());
417 tracing::debug!(
418 "Set working branch for stack '{}' to '{}'",
419 stack.name,
420 current_branch
421 );
422 }
423
424 if current_branch != stack.base_branch && current_branch != "HEAD" {
425 let base_exists = self.repo.branch_exists(&stack.base_branch);
427 let current_is_feature = current_branch.starts_with("feature/")
428 || current_branch.starts_with("fix/")
429 || current_branch.starts_with("chore/")
430 || current_branch.contains("feature")
431 || current_branch.contains("fix");
432
433 if base_exists && current_is_feature {
434 tracing::debug!(
435 "First commit detected: updating stack '{}' base branch from '{}' to '{}'",
436 stack.name,
437 stack.base_branch,
438 current_branch
439 );
440
441 Output::info("Smart Base Branch Update:");
442 Output::sub_item(format!(
443 "Stack '{}' was created with base '{}'",
444 stack.name, stack.base_branch
445 ));
446 Output::sub_item(format!(
447 "You're now working on feature branch '{current_branch}'"
448 ));
449 Output::sub_item("Updating stack base branch to match your workflow");
450
451 stack.base_branch = current_branch.clone();
453
454 if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
456 stack_meta.base_branch = current_branch.clone();
457 stack_meta.set_current_branch(Some(current_branch.clone()));
458 }
459
460 println!(
461 " ā
Stack '{}' base branch updated to '{current_branch}'",
462 stack.name
463 );
464 }
465 }
466 }
467
468 if self.repo.branch_exists(&branch) {
471 self.repo
475 .update_branch_to_commit(&branch, &commit_hash)
476 .map_err(|e| {
477 CascadeError::branch(format!(
478 "Failed to update existing branch '{}' to commit {}: {}",
479 branch,
480 &commit_hash[..8],
481 e
482 ))
483 })?;
484 } else {
485 self.repo
487 .create_branch(&branch, Some(&commit_hash))
488 .map_err(|e| {
489 CascadeError::branch(format!(
490 "Failed to create branch '{}' from commit {}: {}",
491 branch,
492 &commit_hash[..8],
493 e
494 ))
495 })?;
496
497 }
499
500 let entry_id = stack.push_entry(branch.clone(), commit_hash.clone(), message.clone());
502
503 let commit_metadata = CommitMetadata::new(
505 commit_hash.clone(),
506 message,
507 entry_id,
508 stack_id,
509 branch.clone(),
510 source_branch,
511 );
512
513 self.metadata.add_commit(commit_metadata);
515 if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
516 stack_meta.add_branch(branch);
517 stack_meta.add_commit(commit_hash);
518 }
519
520 self.save_to_disk()?;
521
522 Ok(entry_id)
523 }
524
525 pub fn pop_from_stack(&mut self) -> Result<StackEntry> {
527 let stack_id = self
528 .metadata
529 .active_stack_id
530 .ok_or_else(|| CascadeError::config("No active stack"))?;
531
532 let stack = self
533 .stacks
534 .get_mut(&stack_id)
535 .ok_or_else(|| CascadeError::config("Active stack not found"))?;
536
537 let entry = stack
538 .pop_entry()
539 .ok_or_else(|| CascadeError::config("Stack is empty"))?;
540
541 self.metadata.remove_commit(&entry.commit_hash);
543
544 if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
546 stack_meta.remove_commit(&entry.commit_hash);
547 }
549
550 self.save_to_disk()?;
551
552 Ok(entry)
553 }
554
555 pub fn submit_entry(
557 &mut self,
558 stack_id: &Uuid,
559 entry_id: &Uuid,
560 pull_request_id: String,
561 ) -> Result<()> {
562 let stack = self
563 .stacks
564 .get_mut(stack_id)
565 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
566
567 let entry_commit_hash = {
568 let entry = stack
569 .get_entry(entry_id)
570 .ok_or_else(|| CascadeError::config(format!("Entry {entry_id} not found")))?;
571 entry.commit_hash.clone()
572 };
573
574 if !stack.mark_entry_submitted(entry_id, pull_request_id.clone()) {
576 return Err(CascadeError::config(format!(
577 "Failed to mark entry {entry_id} as submitted"
578 )));
579 }
580
581 if let Some(commit_meta) = self.metadata.commits.get_mut(&entry_commit_hash) {
583 commit_meta.mark_submitted(pull_request_id);
584 }
585
586 if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
588 let submitted_count = stack.entries.iter().filter(|e| e.is_submitted).count();
589 let merged_count = stack.entries.iter().filter(|e| e.is_merged).count();
590 stack_meta.update_stats(stack.entries.len(), submitted_count, merged_count);
591 }
592
593 self.save_to_disk()?;
594
595 Ok(())
596 }
597
598 pub fn remove_stack_entry(
600 &mut self,
601 stack_id: &Uuid,
602 entry_id: &Uuid,
603 ) -> Result<Option<StackEntry>> {
604 let stack = match self.stacks.get_mut(stack_id) {
605 Some(stack) => stack,
606 None => return Err(CascadeError::config(format!("Stack {stack_id} not found"))),
607 };
608
609 let entry = match stack.entry_map.get(entry_id) {
610 Some(entry) => entry.clone(),
611 None => return Ok(None),
612 };
613
614 if !entry.children.is_empty() {
615 warn!(
616 "Skipping removal of stack entry {} (branch '{}') because it still has {} child entr{}",
617 entry.id,
618 entry.branch,
619 entry.children.len(),
620 if entry.children.len() == 1 { "y" } else { "ies" }
621 );
622 return Ok(None);
623 }
624
625 stack.entries.retain(|e| e.id != entry.id);
627
628 stack.entry_map.remove(&entry.id);
630
631 if let Some(parent_id) = entry.parent_id {
633 if let Some(parent) = stack.entry_map.get_mut(&parent_id) {
634 parent.children.retain(|child| child != &entry.id);
635 }
636 }
637
638 stack.repair_data_consistency();
640 stack.updated_at = Utc::now();
641
642 self.metadata.remove_commit(&entry.commit_hash);
644 if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
645 stack_meta.remove_commit(&entry.commit_hash);
646 stack_meta.remove_branch(&entry.branch);
647
648 let submitted = stack.entries.iter().filter(|e| e.is_submitted).count();
649 let merged = stack.entries.iter().filter(|e| e.is_merged).count();
650 stack_meta.update_stats(stack.entries.len(), submitted, merged);
651 }
652
653 self.save_to_disk()?;
654
655 Ok(Some(entry))
656 }
657
658 pub fn set_entry_merged(
660 &mut self,
661 stack_id: &Uuid,
662 entry_id: &Uuid,
663 merged: bool,
664 ) -> Result<()> {
665 let stack = self
666 .stacks
667 .get_mut(stack_id)
668 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
669
670 let current_entry = stack
671 .entry_map
672 .get(entry_id)
673 .cloned()
674 .ok_or_else(|| CascadeError::config(format!("Entry {entry_id} not found")))?;
675
676 if current_entry.is_merged == merged {
677 return Ok(());
678 }
679
680 if !stack.mark_entry_merged(entry_id, merged) {
681 return Err(CascadeError::config(format!(
682 "Entry {entry_id} not found in stack {stack_id}"
683 )));
684 }
685
686 if let Some(commit_meta) = self.metadata.commits.get_mut(¤t_entry.commit_hash) {
688 commit_meta.mark_merged(merged);
689 }
690
691 if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
693 let submitted_count = stack.entries.iter().filter(|e| e.is_submitted).count();
694 let merged_count = stack.entries.iter().filter(|e| e.is_merged).count();
695 stack_meta.update_stats(stack.entries.len(), submitted_count, merged_count);
696 }
697
698 self.save_to_disk()?;
699
700 Ok(())
701 }
702
703 pub fn repair_all_stacks(&mut self) -> Result<()> {
705 for stack in self.stacks.values_mut() {
706 stack.repair_data_consistency();
707 }
708 self.save_to_disk()?;
709 Ok(())
710 }
711
712 pub fn get_all_stacks(&self) -> Vec<&Stack> {
714 self.stacks.values().collect()
715 }
716
717 pub fn get_stack_metadata(&self, stack_id: &Uuid) -> Option<&StackMetadata> {
719 self.metadata.get_stack(stack_id)
720 }
721
722 pub fn get_repository_metadata(&self) -> &RepositoryMetadata {
724 &self.metadata
725 }
726
727 pub fn git_repo(&self) -> &GitRepository {
729 &self.repo
730 }
731
732 pub fn repo_path(&self) -> &Path {
734 &self.repo_path
735 }
736
737 pub fn is_in_edit_mode(&self) -> bool {
741 self.metadata
742 .edit_mode
743 .as_ref()
744 .map(|edit_state| edit_state.is_active)
745 .unwrap_or(false)
746 }
747
748 pub fn get_edit_mode_info(&self) -> Option<&super::metadata::EditModeState> {
750 self.metadata.edit_mode.as_ref()
751 }
752
753 pub fn enter_edit_mode(&mut self, stack_id: Uuid, entry_id: Uuid) -> Result<()> {
755 let commit_hash = {
757 let stack = self
758 .get_stack(&stack_id)
759 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
760
761 let entry = stack.get_entry(&entry_id).ok_or_else(|| {
762 CascadeError::config(format!("Entry {entry_id} not found in stack"))
763 })?;
764
765 entry.commit_hash.clone()
766 };
767
768 if self.is_in_edit_mode() {
770 self.exit_edit_mode()?;
771 }
772
773 let edit_state = super::metadata::EditModeState::new(stack_id, entry_id, commit_hash);
775
776 self.metadata.edit_mode = Some(edit_state);
777 self.save_to_disk()?;
778
779 debug!(
780 "Entered edit mode for entry {} in stack {}",
781 entry_id, stack_id
782 );
783 Ok(())
784 }
785
786 pub fn exit_edit_mode(&mut self) -> Result<()> {
788 if !self.is_in_edit_mode() {
789 return Err(CascadeError::config("Not currently in edit mode"));
790 }
791
792 self.metadata.edit_mode = None;
794 self.save_to_disk()?;
795
796 debug!("Exited edit mode");
797 Ok(())
798 }
799
800 pub fn sync_stack(&mut self, stack_id: &Uuid) -> Result<()> {
802 let stack = self
803 .stacks
804 .get_mut(stack_id)
805 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
806
807 if let Err(integrity_error) = stack.validate_git_integrity(&self.repo) {
809 stack.update_status(StackStatus::Corrupted);
810 return Err(CascadeError::branch(format!(
811 "Stack '{}' Git integrity check failed:\n{}",
812 stack.name, integrity_error
813 )));
814 }
815
816 let mut missing_commits = Vec::new();
818 for entry in &stack.entries {
819 if !self.repo.commit_exists(&entry.commit_hash)? {
820 missing_commits.push(entry.commit_hash.clone());
821 }
822 }
823
824 if !missing_commits.is_empty() {
825 stack.update_status(StackStatus::Corrupted);
826 return Err(CascadeError::branch(format!(
827 "Stack {} has missing commits: {}",
828 stack.name,
829 missing_commits.join(", ")
830 )));
831 }
832
833 if !self.repo.branch_exists_or_fetch(&stack.base_branch)? {
835 return Err(CascadeError::branch(format!(
836 "Base branch '{}' does not exist locally or remotely. Check the branch name or switch to a different base.",
837 stack.base_branch
838 )));
839 }
840
841 let _base_hash = self.repo.get_branch_head(&stack.base_branch)?;
842
843 let mut corrupted_entry = None;
845 for entry in &stack.entries {
846 if !self.repo.commit_exists(&entry.commit_hash)? {
847 corrupted_entry = Some((entry.commit_hash.clone(), entry.branch.clone()));
848 break;
849 }
850 }
851
852 if let Some((commit_hash, branch)) = corrupted_entry {
853 stack.update_status(StackStatus::Corrupted);
854 return Err(CascadeError::branch(format!(
855 "Commit {commit_hash} from stack entry '{branch}' no longer exists"
856 )));
857 }
858
859 let needs_sync = if let Some(first_entry) = stack.entries.first() {
861 match self
863 .repo
864 .get_commits_between(&stack.base_branch, &first_entry.commit_hash)
865 {
866 Ok(commits) => !commits.is_empty(), Err(_) => true, }
869 } else {
870 false };
872
873 if needs_sync {
875 stack.update_status(StackStatus::NeedsSync);
876 debug!(
877 "Stack '{}' needs sync - new commits on base branch",
878 stack.name
879 );
880 } else {
881 stack.update_status(StackStatus::Clean);
882 debug!("Stack '{}' is clean", stack.name);
883 }
884
885 if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
887 stack_meta.set_up_to_date(true);
888 }
889
890 self.save_to_disk()?;
891
892 Ok(())
893 }
894
895 pub fn list_stacks(&self) -> Vec<(Uuid, &str, &StackStatus, usize, Option<&str>)> {
897 self.stacks
898 .values()
899 .map(|stack| {
900 (
901 stack.id,
902 stack.name.as_str(),
903 &stack.status,
904 stack.entries.len(),
905 if stack.is_active {
906 Some("active")
907 } else {
908 None
909 },
910 )
911 })
912 .collect()
913 }
914
915 pub fn get_all_stacks_objects(&self) -> Result<Vec<Stack>> {
917 let mut stacks: Vec<Stack> = self.stacks.values().cloned().collect();
918 stacks.sort_by(|a, b| a.name.cmp(&b.name));
919 Ok(stacks)
920 }
921
922 pub fn validate_all(&self) -> Result<()> {
924 for stack in self.stacks.values() {
925 stack.validate().map_err(|e| {
927 CascadeError::config(format!("Stack '{}' validation failed: {}", stack.name, e))
928 })?;
929
930 stack.validate_git_integrity(&self.repo).map_err(|e| {
932 CascadeError::config(format!(
933 "Stack '{}' Git integrity validation failed: {}",
934 stack.name, e
935 ))
936 })?;
937 }
938 Ok(())
939 }
940
941 pub fn validate_stack(&self, stack_id: &Uuid) -> Result<()> {
943 let stack = self
944 .stacks
945 .get(stack_id)
946 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
947
948 stack.validate().map_err(|e| {
950 CascadeError::config(format!("Stack '{}' validation failed: {}", stack.name, e))
951 })?;
952
953 stack.validate_git_integrity(&self.repo).map_err(|e| {
955 CascadeError::config(format!(
956 "Stack '{}' Git integrity validation failed: {}",
957 stack.name, e
958 ))
959 })?;
960
961 Ok(())
962 }
963
964 pub fn save_to_disk(&self) -> Result<()> {
966 if !self.config_dir.exists() {
968 fs::create_dir_all(&self.config_dir).map_err(|e| {
969 CascadeError::config(format!("Failed to create config directory: {e}"))
970 })?;
971 }
972
973 crate::utils::atomic_file::write_json(&self.stacks_file, &self.stacks)?;
975
976 crate::utils::atomic_file::write_json(&self.metadata_file, &self.metadata)?;
978
979 Ok(())
980 }
981
982 fn load_from_disk(&mut self) -> Result<()> {
984 if self.stacks_file.exists() {
986 let stacks_content = fs::read_to_string(&self.stacks_file)
987 .map_err(|e| CascadeError::config(format!("Failed to read stacks file: {e}")))?;
988
989 self.stacks = serde_json::from_str(&stacks_content)
990 .map_err(|e| CascadeError::config(format!("Failed to parse stacks file: {e}")))?;
991 }
992
993 if self.metadata_file.exists() {
995 let metadata_content = fs::read_to_string(&self.metadata_file)
996 .map_err(|e| CascadeError::config(format!("Failed to read metadata file: {e}")))?;
997
998 self.metadata = serde_json::from_str(&metadata_content)
999 .map_err(|e| CascadeError::config(format!("Failed to parse metadata file: {e}")))?;
1000 }
1001
1002 Ok(())
1003 }
1004
1005 pub fn check_for_branch_change(&mut self) -> Result<bool> {
1008 let (stack_id, stack_name, stored_branch) = {
1010 if let Some(active_stack) = self.get_active_stack() {
1011 let stack_id = active_stack.id;
1012 let stack_name = active_stack.name.clone();
1013 let stored_branch = if let Some(stack_meta) = self.metadata.get_stack(&stack_id) {
1014 stack_meta.current_branch.clone()
1015 } else {
1016 None
1017 };
1018 (Some(stack_id), stack_name, stored_branch)
1019 } else {
1020 (None, String::new(), None)
1021 }
1022 };
1023
1024 let Some(stack_id) = stack_id else {
1026 return Ok(true);
1027 };
1028
1029 let current_branch = self.repo.get_current_branch().ok();
1030
1031 if stored_branch.as_ref() != current_branch.as_ref() {
1033 Output::warning("Branch change detected!");
1034 Output::sub_item(format!(
1035 "Stack '{}' was active on: {}",
1036 stack_name,
1037 stored_branch.as_deref().unwrap_or("unknown")
1038 ));
1039 Output::sub_item(format!(
1040 "Current branch: {}",
1041 current_branch.as_deref().unwrap_or("unknown")
1042 ));
1043 Output::spacing();
1044
1045 let options = vec![
1046 format!("Keep stack '{stack_name}' active (continue with stack workflow)"),
1047 "Deactivate stack (use normal Git workflow)".to_string(),
1048 "Switch to a different stack".to_string(),
1049 "Cancel and stay on current workflow".to_string(),
1050 ];
1051
1052 let choice = Select::with_theme(&ColorfulTheme::default())
1053 .with_prompt("What would you like to do?")
1054 .default(0)
1055 .items(&options)
1056 .interact()
1057 .map_err(|e| CascadeError::config(format!("Failed to get user choice: {e}")))?;
1058
1059 match choice {
1060 0 => {
1061 if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
1063 stack_meta.set_current_branch(current_branch);
1064 }
1065 self.save_to_disk()?;
1066 Output::success(format!(
1067 "Continuing with stack '{stack_name}' on current branch"
1068 ));
1069 return Ok(true);
1070 }
1071 1 => {
1072 self.set_active_stack(None)?;
1074 Output::success(format!(
1075 "Deactivated stack '{stack_name}' - using normal Git workflow"
1076 ));
1077 return Ok(false);
1078 }
1079 2 => {
1080 let stacks = self.get_all_stacks();
1082 if stacks.len() <= 1 {
1083 Output::warning("No other stacks available. Deactivating current stack.");
1084 self.set_active_stack(None)?;
1085 return Ok(false);
1086 }
1087
1088 Output::spacing();
1089 Output::info("Available stacks:");
1090 for (i, stack) in stacks.iter().enumerate() {
1091 if stack.id != stack_id {
1092 Output::numbered_item(i + 1, &stack.name);
1093 }
1094 }
1095 let stack_name_input: String = Input::with_theme(&ColorfulTheme::default())
1096 .with_prompt("Enter stack name")
1097 .validate_with(|input: &String| -> std::result::Result<(), &str> {
1098 if input.trim().is_empty() {
1099 Err("Stack name cannot be empty")
1100 } else {
1101 Ok(())
1102 }
1103 })
1104 .interact_text()
1105 .map_err(|e| {
1106 CascadeError::config(format!("Failed to get user input: {e}"))
1107 })?;
1108 let stack_name_input = stack_name_input.trim();
1109
1110 if let Err(e) = self.set_active_stack_by_name(stack_name_input) {
1111 Output::warning(format!("{e}"));
1112 Output::sub_item("Deactivating stack instead.");
1113 self.set_active_stack(None)?;
1114 return Ok(false);
1115 } else {
1116 Output::success(format!("Switched to stack '{stack_name_input}'"));
1117 return Ok(true);
1118 }
1119 }
1120 3 => {
1121 Output::info("Cancelled - no changes made");
1122 return Ok(false);
1123 }
1124 _ => {
1125 Output::info("Invalid choice - no changes made");
1126 return Ok(false);
1127 }
1128 }
1129 }
1130
1131 Ok(true)
1133 }
1134
1135 pub fn handle_branch_modifications(
1138 &mut self,
1139 stack_id: &Uuid,
1140 auto_mode: Option<String>,
1141 ) -> Result<()> {
1142 let stack = self
1143 .stacks
1144 .get_mut(stack_id)
1145 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
1146
1147 debug!("Checking Git integrity for stack '{}'", stack.name);
1148
1149 let mut modifications = Vec::new();
1151 for entry in &stack.entries {
1152 if !self.repo.branch_exists(&entry.branch) {
1153 modifications.push(BranchModification::Missing {
1154 branch: entry.branch.clone(),
1155 entry_id: entry.id,
1156 expected_commit: entry.commit_hash.clone(),
1157 });
1158 } else if let Ok(branch_head) = self.repo.get_branch_head(&entry.branch) {
1159 if branch_head != entry.commit_hash {
1160 let extra_commits = self
1162 .repo
1163 .get_commits_between(&entry.commit_hash, &branch_head)?;
1164 let mut extra_messages = Vec::new();
1165 for commit in &extra_commits {
1166 if let Some(message) = commit.message() {
1167 let first_line =
1168 message.lines().next().unwrap_or("(no message)").to_string();
1169 extra_messages.push(format!(
1170 "{}: {}",
1171 &commit.id().to_string()[..8],
1172 first_line
1173 ));
1174 }
1175 }
1176
1177 modifications.push(BranchModification::ExtraCommits {
1178 branch: entry.branch.clone(),
1179 entry_id: entry.id,
1180 expected_commit: entry.commit_hash.clone(),
1181 actual_commit: branch_head,
1182 extra_commit_count: extra_commits.len(),
1183 extra_commit_messages: extra_messages,
1184 });
1185 }
1186 }
1187 }
1188
1189 if modifications.is_empty() {
1190 return Ok(());
1192 }
1193
1194 println!();
1196 Output::section(format!("Branch modifications detected in '{}'", stack.name));
1197 for (i, modification) in modifications.iter().enumerate() {
1198 match modification {
1199 BranchModification::Missing { branch, .. } => {
1200 Output::numbered_item(i + 1, format!("Branch '{branch}' is missing"));
1201 }
1202 BranchModification::ExtraCommits {
1203 branch,
1204 expected_commit,
1205 actual_commit,
1206 extra_commit_count,
1207 extra_commit_messages,
1208 ..
1209 } => {
1210 println!(
1211 " {}. Branch '{}' has {} extra commit(s)",
1212 i + 1,
1213 branch,
1214 extra_commit_count
1215 );
1216 println!(
1217 " Expected: {} | Actual: {}",
1218 &expected_commit[..8],
1219 &actual_commit[..8]
1220 );
1221
1222 for (j, message) in extra_commit_messages.iter().enumerate() {
1224 match j.cmp(&3) {
1225 std::cmp::Ordering::Less => {
1226 Output::sub_item(format!(" + {message}"));
1227 }
1228 std::cmp::Ordering::Equal => {
1229 Output::sub_item(format!(
1230 " + ... and {} more",
1231 extra_commit_count - 3
1232 ));
1233 break;
1234 }
1235 std::cmp::Ordering::Greater => {
1236 break;
1237 }
1238 }
1239 }
1240 }
1241 }
1242 }
1243 Output::spacing();
1244
1245 if let Some(mode) = auto_mode {
1247 return self.apply_auto_fix(stack_id, &modifications, &mode);
1248 }
1249
1250 let mut handled_count = 0;
1252 let mut skipped_count = 0;
1253 for modification in modifications.iter() {
1254 let was_skipped = self.handle_single_modification(stack_id, modification)?;
1255 if was_skipped {
1256 skipped_count += 1;
1257 } else {
1258 handled_count += 1;
1259 }
1260 }
1261
1262 self.save_to_disk()?;
1263
1264 if skipped_count == 0 {
1266 Output::success("All branch modifications resolved");
1267 } else if handled_count > 0 {
1268 Output::warning(format!(
1269 "Resolved {} modification(s), {} skipped",
1270 handled_count, skipped_count
1271 ));
1272 } else {
1273 Output::warning("All modifications skipped - integrity issues remain");
1274 }
1275
1276 Ok(())
1277 }
1278
1279 fn handle_single_modification(
1282 &mut self,
1283 stack_id: &Uuid,
1284 modification: &BranchModification,
1285 ) -> Result<bool> {
1286 match modification {
1287 BranchModification::Missing {
1288 branch,
1289 expected_commit,
1290 ..
1291 } => {
1292 Output::info(format!("Missing branch '{branch}'"));
1293 Output::sub_item(format!(
1294 "Will create the branch at commit {}",
1295 &expected_commit[..8]
1296 ));
1297
1298 self.repo.create_branch(branch, Some(expected_commit))?;
1299 Output::success(format!("Created branch '{branch}'"));
1300 Ok(false) }
1302
1303 BranchModification::ExtraCommits {
1304 branch,
1305 entry_id,
1306 expected_commit,
1307 extra_commit_count,
1308 ..
1309 } => {
1310 println!();
1311 Output::info(format!(
1312 "Branch '{}' has {} extra commit(s)",
1313 branch, extra_commit_count
1314 ));
1315 let options = vec![
1316 "Incorporate - Update stack entry to include extra commits",
1317 "Split - Create new stack entry for extra commits",
1318 "Reset - Remove extra commits (DESTRUCTIVE)",
1319 "Skip - Leave as-is for now",
1320 ];
1321
1322 let choice = Select::with_theme(&ColorfulTheme::default())
1323 .with_prompt("Choose how to handle extra commits")
1324 .default(0)
1325 .items(&options)
1326 .interact()
1327 .map_err(|e| CascadeError::config(format!("Failed to get user choice: {e}")))?;
1328
1329 match choice {
1330 0 => {
1331 self.incorporate_extra_commits(stack_id, *entry_id, branch)?;
1332 Ok(false) }
1334 1 => {
1335 self.split_extra_commits(stack_id, *entry_id, branch)?;
1336 Ok(false) }
1338 2 => {
1339 self.reset_branch_destructive(branch, expected_commit)?;
1340 Ok(false) }
1342 3 => {
1343 Output::warning(format!("Skipped '{branch}' - integrity issue remains"));
1344 Ok(true) }
1346 _ => {
1347 Output::warning(format!("Invalid choice - skipped '{branch}'"));
1348 Ok(true) }
1350 }
1351 }
1352 }
1353 }
1354
1355 fn apply_auto_fix(
1357 &mut self,
1358 stack_id: &Uuid,
1359 modifications: &[BranchModification],
1360 mode: &str,
1361 ) -> Result<()> {
1362 Output::info(format!("š¤ Applying automatic fix mode: {mode}"));
1363
1364 for modification in modifications {
1365 match (modification, mode) {
1366 (
1367 BranchModification::Missing {
1368 branch,
1369 expected_commit,
1370 ..
1371 },
1372 _,
1373 ) => {
1374 self.repo.create_branch(branch, Some(expected_commit))?;
1375 Output::success(format!("Created missing branch '{branch}'"));
1376 }
1377
1378 (
1379 BranchModification::ExtraCommits {
1380 branch, entry_id, ..
1381 },
1382 "incorporate",
1383 ) => {
1384 self.incorporate_extra_commits(stack_id, *entry_id, branch)?;
1385 }
1386
1387 (
1388 BranchModification::ExtraCommits {
1389 branch, entry_id, ..
1390 },
1391 "split",
1392 ) => {
1393 self.split_extra_commits(stack_id, *entry_id, branch)?;
1394 }
1395
1396 (
1397 BranchModification::ExtraCommits {
1398 branch,
1399 expected_commit,
1400 ..
1401 },
1402 "reset",
1403 ) => {
1404 self.reset_branch_destructive(branch, expected_commit)?;
1405 }
1406
1407 _ => {
1408 return Err(CascadeError::config(format!(
1409 "Unknown auto-fix mode '{mode}'. Use: incorporate, split, reset"
1410 )));
1411 }
1412 }
1413 }
1414
1415 self.save_to_disk()?;
1416 Output::success(format!("Auto-fix completed for mode: {mode}"));
1417 Ok(())
1418 }
1419
1420 fn incorporate_extra_commits(
1422 &mut self,
1423 stack_id: &Uuid,
1424 entry_id: Uuid,
1425 branch: &str,
1426 ) -> Result<()> {
1427 let stack = self
1428 .stacks
1429 .get_mut(stack_id)
1430 .ok_or_else(|| CascadeError::config(format!("Stack not found: {}", stack_id)))?;
1431
1432 let entry_info = stack
1434 .entries
1435 .iter()
1436 .find(|e| e.id == entry_id)
1437 .map(|e| (e.commit_hash.clone(), e.id));
1438
1439 if let Some((old_commit_hash, entry_id)) = entry_info {
1440 let new_head = self.repo.get_branch_head(branch)?;
1441 let old_commit = old_commit_hash[..8].to_string();
1442
1443 let extra_commits = self.repo.get_commits_between(&old_commit_hash, &new_head)?;
1445
1446 stack
1450 .update_entry_commit_hash(&entry_id, new_head.clone())
1451 .map_err(CascadeError::config)?;
1452
1453 Output::success(format!(
1454 "Incorporated {} commit(s) into entry '{}'",
1455 extra_commits.len(),
1456 &new_head[..8]
1457 ));
1458 Output::sub_item(format!("Updated: {} -> {}", old_commit, &new_head[..8]));
1459 }
1460
1461 Ok(())
1462 }
1463
1464 fn split_extra_commits(&mut self, stack_id: &Uuid, entry_id: Uuid, branch: &str) -> Result<()> {
1466 let stack = self
1467 .stacks
1468 .get_mut(stack_id)
1469 .ok_or_else(|| CascadeError::config(format!("Stack not found: {}", stack_id)))?;
1470 let new_head = self.repo.get_branch_head(branch)?;
1471
1472 let entry_position = stack
1474 .entries
1475 .iter()
1476 .position(|e| e.id == entry_id)
1477 .ok_or_else(|| CascadeError::config("Entry not found in stack"))?;
1478
1479 let base_name = branch.trim_end_matches(|c: char| c.is_ascii_digit() || c == '-');
1481 let new_branch = format!("{base_name}-continued");
1482
1483 self.repo.create_branch(&new_branch, Some(&new_head))?;
1485
1486 let original_entry = &stack.entries[entry_position];
1488 let original_commit_hash = original_entry.commit_hash.clone(); let extra_commits = self
1490 .repo
1491 .get_commits_between(&original_commit_hash, &new_head)?;
1492
1493 let mut extra_messages = Vec::new();
1495 for commit in &extra_commits {
1496 if let Some(message) = commit.message() {
1497 let first_line = message.lines().next().unwrap_or("").to_string();
1498 extra_messages.push(first_line);
1499 }
1500 }
1501
1502 let new_message = if extra_messages.len() == 1 {
1503 extra_messages[0].clone()
1504 } else {
1505 format!("Combined changes:\n⢠{}", extra_messages.join("\n⢠"))
1506 };
1507
1508 let now = Utc::now();
1510 let new_entry = crate::stack::StackEntry {
1511 id: uuid::Uuid::new_v4(),
1512 branch: new_branch.clone(),
1513 commit_hash: new_head,
1514 message: new_message,
1515 parent_id: Some(entry_id), children: Vec::new(),
1517 created_at: now,
1518 updated_at: now,
1519 is_submitted: false,
1520 pull_request_id: None,
1521 is_synced: false,
1522 is_merged: false,
1523 };
1524
1525 stack.entries.insert(entry_position + 1, new_entry);
1527
1528 self.repo
1530 .reset_branch_to_commit(branch, &original_commit_hash)?;
1531
1532 println!(
1533 " ā
Split {} commit(s) into new entry '{}'",
1534 extra_commits.len(),
1535 new_branch
1536 );
1537 println!(" Original branch '{branch}' reset to expected commit");
1538
1539 Ok(())
1540 }
1541
1542 fn reset_branch_destructive(&self, branch: &str, expected_commit: &str) -> Result<()> {
1544 self.repo.reset_branch_to_commit(branch, expected_commit)?;
1545 Output::warning(format!(
1546 "Reset branch '{}' to {} (extra commits lost)",
1547 branch,
1548 &expected_commit[..8]
1549 ));
1550 Ok(())
1551 }
1552}
1553
1554#[cfg(test)]
1555mod tests {
1556 use super::*;
1557 use std::process::Command;
1558 use tempfile::TempDir;
1559
1560 fn create_test_repo() -> (TempDir, PathBuf) {
1561 let temp_dir = TempDir::new().unwrap();
1562 let repo_path = temp_dir.path().to_path_buf();
1563
1564 Command::new("git")
1566 .args(["init"])
1567 .current_dir(&repo_path)
1568 .output()
1569 .unwrap();
1570
1571 Command::new("git")
1573 .args(["config", "user.name", "Test User"])
1574 .current_dir(&repo_path)
1575 .output()
1576 .unwrap();
1577
1578 Command::new("git")
1579 .args(["config", "user.email", "test@example.com"])
1580 .current_dir(&repo_path)
1581 .output()
1582 .unwrap();
1583
1584 std::fs::write(repo_path.join("README.md"), "# Test Repo").unwrap();
1586 Command::new("git")
1587 .args(["add", "."])
1588 .current_dir(&repo_path)
1589 .output()
1590 .unwrap();
1591
1592 Command::new("git")
1593 .args(["commit", "-m", "Initial commit"])
1594 .current_dir(&repo_path)
1595 .output()
1596 .unwrap();
1597
1598 crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
1600 .unwrap();
1601
1602 (temp_dir, repo_path)
1603 }
1604
1605 #[test]
1606 fn test_create_stack_manager() {
1607 let (_temp_dir, repo_path) = create_test_repo();
1608 let manager = StackManager::new(&repo_path).unwrap();
1609
1610 assert_eq!(manager.stacks.len(), 0);
1611 assert!(manager.get_active_stack().is_none());
1612 }
1613
1614 #[test]
1615 fn test_create_and_manage_stack() {
1616 let (_temp_dir, repo_path) = create_test_repo();
1617 let mut manager = StackManager::new(&repo_path).unwrap();
1618
1619 let stack_id = manager
1621 .create_stack(
1622 "test-stack".to_string(),
1623 None, Some("Test stack description".to_string()),
1625 )
1626 .unwrap();
1627
1628 assert_eq!(manager.stacks.len(), 1);
1630 let stack = manager.get_stack(&stack_id).unwrap();
1631 assert_eq!(stack.name, "test-stack");
1632 assert!(!stack.base_branch.is_empty());
1634 assert!(stack.is_active);
1635
1636 let active = manager.get_active_stack().unwrap();
1638 assert_eq!(active.id, stack_id);
1639
1640 let found = manager.get_stack_by_name("test-stack").unwrap();
1642 assert_eq!(found.id, stack_id);
1643 }
1644
1645 #[test]
1646 fn test_stack_persistence() {
1647 let (_temp_dir, repo_path) = create_test_repo();
1648
1649 let stack_id = {
1650 let mut manager = StackManager::new(&repo_path).unwrap();
1651 manager
1652 .create_stack("persistent-stack".to_string(), None, None)
1653 .unwrap()
1654 };
1655
1656 let manager = StackManager::new(&repo_path).unwrap();
1658 assert_eq!(manager.stacks.len(), 1);
1659 let stack = manager.get_stack(&stack_id).unwrap();
1660 assert_eq!(stack.name, "persistent-stack");
1661 }
1662
1663 #[test]
1664 fn test_multiple_stacks() {
1665 let (_temp_dir, repo_path) = create_test_repo();
1666 let mut manager = StackManager::new(&repo_path).unwrap();
1667
1668 let stack1_id = manager
1669 .create_stack("stack-1".to_string(), None, None)
1670 .unwrap();
1671 let stack2_id = manager
1672 .create_stack("stack-2".to_string(), None, None)
1673 .unwrap();
1674
1675 assert_eq!(manager.stacks.len(), 2);
1676
1677 assert!(!manager.get_stack(&stack1_id).unwrap().is_active);
1679 assert!(manager.get_stack(&stack2_id).unwrap().is_active);
1680
1681 manager.set_active_stack(Some(stack2_id)).unwrap();
1683 assert!(!manager.get_stack(&stack1_id).unwrap().is_active);
1684 assert!(manager.get_stack(&stack2_id).unwrap().is_active);
1685 }
1686
1687 #[test]
1688 fn test_delete_stack() {
1689 let (_temp_dir, repo_path) = create_test_repo();
1690 let mut manager = StackManager::new(&repo_path).unwrap();
1691
1692 let stack_id = manager
1693 .create_stack("to-delete".to_string(), None, None)
1694 .unwrap();
1695 assert_eq!(manager.stacks.len(), 1);
1696
1697 let deleted = manager.delete_stack(&stack_id).unwrap();
1698 assert_eq!(deleted.name, "to-delete");
1699 assert_eq!(manager.stacks.len(), 0);
1700 assert!(manager.get_active_stack().is_none());
1701 }
1702
1703 #[test]
1704 fn test_validation() {
1705 let (_temp_dir, repo_path) = create_test_repo();
1706 let mut manager = StackManager::new(&repo_path).unwrap();
1707
1708 manager
1709 .create_stack("valid-stack".to_string(), None, None)
1710 .unwrap();
1711
1712 assert!(manager.validate_all().is_ok());
1714 }
1715
1716 #[test]
1717 fn test_duplicate_commit_message_detection() {
1718 let (_temp_dir, repo_path) = create_test_repo();
1719 let mut manager = StackManager::new(&repo_path).unwrap();
1720
1721 manager
1723 .create_stack("test-stack".to_string(), None, None)
1724 .unwrap();
1725
1726 std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1728 Command::new("git")
1729 .args(["add", "file1.txt"])
1730 .current_dir(&repo_path)
1731 .output()
1732 .unwrap();
1733
1734 Command::new("git")
1735 .args(["commit", "-m", "Add authentication feature"])
1736 .current_dir(&repo_path)
1737 .output()
1738 .unwrap();
1739
1740 let commit1_hash = Command::new("git")
1741 .args(["rev-parse", "HEAD"])
1742 .current_dir(&repo_path)
1743 .output()
1744 .unwrap();
1745 let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1746 .trim()
1747 .to_string();
1748
1749 let entry1_id = manager
1751 .push_to_stack(
1752 "feature/auth".to_string(),
1753 commit1_hash,
1754 "Add authentication feature".to_string(),
1755 "main".to_string(),
1756 )
1757 .unwrap();
1758
1759 assert!(manager
1761 .get_active_stack()
1762 .unwrap()
1763 .get_entry(&entry1_id)
1764 .is_some());
1765
1766 std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1768 Command::new("git")
1769 .args(["add", "file2.txt"])
1770 .current_dir(&repo_path)
1771 .output()
1772 .unwrap();
1773
1774 Command::new("git")
1775 .args(["commit", "-m", "Different commit message"])
1776 .current_dir(&repo_path)
1777 .output()
1778 .unwrap();
1779
1780 let commit2_hash = Command::new("git")
1781 .args(["rev-parse", "HEAD"])
1782 .current_dir(&repo_path)
1783 .output()
1784 .unwrap();
1785 let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1786 .trim()
1787 .to_string();
1788
1789 let result = manager.push_to_stack(
1791 "feature/auth2".to_string(),
1792 commit2_hash.clone(),
1793 "Add authentication feature".to_string(), "main".to_string(),
1795 );
1796
1797 assert!(result.is_err());
1799 let error = result.unwrap_err();
1800 assert!(matches!(error, CascadeError::Validation(_)));
1801
1802 let error_msg = error.to_string();
1804 assert!(error_msg.contains("Duplicate commit message"));
1805 assert!(error_msg.contains("Add authentication feature"));
1806 assert!(error_msg.contains("š” Consider using a more specific message"));
1807
1808 let entry2_id = manager
1810 .push_to_stack(
1811 "feature/auth2".to_string(),
1812 commit2_hash,
1813 "Add authentication validation".to_string(), "main".to_string(),
1815 )
1816 .unwrap();
1817
1818 let stack = manager.get_active_stack().unwrap();
1820 assert_eq!(stack.entries.len(), 2);
1821 assert!(stack.get_entry(&entry1_id).is_some());
1822 assert!(stack.get_entry(&entry2_id).is_some());
1823 }
1824
1825 #[test]
1826 fn test_duplicate_message_with_different_case() {
1827 let (_temp_dir, repo_path) = create_test_repo();
1828 let mut manager = StackManager::new(&repo_path).unwrap();
1829
1830 manager
1831 .create_stack("test-stack".to_string(), None, None)
1832 .unwrap();
1833
1834 std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1836 Command::new("git")
1837 .args(["add", "file1.txt"])
1838 .current_dir(&repo_path)
1839 .output()
1840 .unwrap();
1841
1842 Command::new("git")
1843 .args(["commit", "-m", "fix bug"])
1844 .current_dir(&repo_path)
1845 .output()
1846 .unwrap();
1847
1848 let commit1_hash = Command::new("git")
1849 .args(["rev-parse", "HEAD"])
1850 .current_dir(&repo_path)
1851 .output()
1852 .unwrap();
1853 let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1854 .trim()
1855 .to_string();
1856
1857 manager
1858 .push_to_stack(
1859 "feature/fix1".to_string(),
1860 commit1_hash,
1861 "fix bug".to_string(),
1862 "main".to_string(),
1863 )
1864 .unwrap();
1865
1866 std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1868 Command::new("git")
1869 .args(["add", "file2.txt"])
1870 .current_dir(&repo_path)
1871 .output()
1872 .unwrap();
1873
1874 Command::new("git")
1875 .args(["commit", "-m", "Fix Bug"])
1876 .current_dir(&repo_path)
1877 .output()
1878 .unwrap();
1879
1880 let commit2_hash = Command::new("git")
1881 .args(["rev-parse", "HEAD"])
1882 .current_dir(&repo_path)
1883 .output()
1884 .unwrap();
1885 let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1886 .trim()
1887 .to_string();
1888
1889 let result = manager.push_to_stack(
1891 "feature/fix2".to_string(),
1892 commit2_hash,
1893 "Fix Bug".to_string(), "main".to_string(),
1895 );
1896
1897 assert!(result.is_ok());
1899 }
1900
1901 #[test]
1902 fn test_duplicate_message_across_different_stacks() {
1903 let (_temp_dir, repo_path) = create_test_repo();
1904 let mut manager = StackManager::new(&repo_path).unwrap();
1905
1906 let stack1_id = manager
1908 .create_stack("stack1".to_string(), None, None)
1909 .unwrap();
1910
1911 std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1912 Command::new("git")
1913 .args(["add", "file1.txt"])
1914 .current_dir(&repo_path)
1915 .output()
1916 .unwrap();
1917
1918 Command::new("git")
1919 .args(["commit", "-m", "shared message"])
1920 .current_dir(&repo_path)
1921 .output()
1922 .unwrap();
1923
1924 let commit1_hash = Command::new("git")
1925 .args(["rev-parse", "HEAD"])
1926 .current_dir(&repo_path)
1927 .output()
1928 .unwrap();
1929 let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1930 .trim()
1931 .to_string();
1932
1933 manager
1934 .push_to_stack(
1935 "feature/shared1".to_string(),
1936 commit1_hash,
1937 "shared message".to_string(),
1938 "main".to_string(),
1939 )
1940 .unwrap();
1941
1942 let stack2_id = manager
1944 .create_stack("stack2".to_string(), None, None)
1945 .unwrap();
1946
1947 manager.set_active_stack(Some(stack2_id)).unwrap();
1949
1950 std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1952 Command::new("git")
1953 .args(["add", "file2.txt"])
1954 .current_dir(&repo_path)
1955 .output()
1956 .unwrap();
1957
1958 Command::new("git")
1959 .args(["commit", "-m", "shared message"])
1960 .current_dir(&repo_path)
1961 .output()
1962 .unwrap();
1963
1964 let commit2_hash = Command::new("git")
1965 .args(["rev-parse", "HEAD"])
1966 .current_dir(&repo_path)
1967 .output()
1968 .unwrap();
1969 let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1970 .trim()
1971 .to_string();
1972
1973 let result = manager.push_to_stack(
1975 "feature/shared2".to_string(),
1976 commit2_hash,
1977 "shared message".to_string(), "main".to_string(),
1979 );
1980
1981 assert!(result.is_ok());
1983
1984 let stack1 = manager.get_stack(&stack1_id).unwrap();
1986 let stack2 = manager.get_stack(&stack2_id).unwrap();
1987
1988 assert_eq!(stack1.entries.len(), 1);
1989 assert_eq!(stack2.entries.len(), 1);
1990 assert_eq!(stack1.entries[0].message, "shared message");
1991 assert_eq!(stack2.entries[0].message, "shared message");
1992 }
1993
1994 #[test]
1995 fn test_duplicate_after_pop() {
1996 let (_temp_dir, repo_path) = create_test_repo();
1997 let mut manager = StackManager::new(&repo_path).unwrap();
1998
1999 manager
2000 .create_stack("test-stack".to_string(), None, None)
2001 .unwrap();
2002
2003 std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
2005 Command::new("git")
2006 .args(["add", "file1.txt"])
2007 .current_dir(&repo_path)
2008 .output()
2009 .unwrap();
2010
2011 Command::new("git")
2012 .args(["commit", "-m", "temporary message"])
2013 .current_dir(&repo_path)
2014 .output()
2015 .unwrap();
2016
2017 let commit1_hash = Command::new("git")
2018 .args(["rev-parse", "HEAD"])
2019 .current_dir(&repo_path)
2020 .output()
2021 .unwrap();
2022 let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
2023 .trim()
2024 .to_string();
2025
2026 manager
2027 .push_to_stack(
2028 "feature/temp".to_string(),
2029 commit1_hash,
2030 "temporary message".to_string(),
2031 "main".to_string(),
2032 )
2033 .unwrap();
2034
2035 let popped = manager.pop_from_stack().unwrap();
2037 assert_eq!(popped.message, "temporary message");
2038
2039 std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
2041 Command::new("git")
2042 .args(["add", "file2.txt"])
2043 .current_dir(&repo_path)
2044 .output()
2045 .unwrap();
2046
2047 Command::new("git")
2048 .args(["commit", "-m", "temporary message"])
2049 .current_dir(&repo_path)
2050 .output()
2051 .unwrap();
2052
2053 let commit2_hash = Command::new("git")
2054 .args(["rev-parse", "HEAD"])
2055 .current_dir(&repo_path)
2056 .output()
2057 .unwrap();
2058 let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
2059 .trim()
2060 .to_string();
2061
2062 let result = manager.push_to_stack(
2064 "feature/temp2".to_string(),
2065 commit2_hash,
2066 "temporary message".to_string(),
2067 "main".to_string(),
2068 );
2069
2070 assert!(result.is_ok());
2071 }
2072}