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 } else {
473 self.repo
475 .create_branch(&branch, Some(&commit_hash))
476 .map_err(|e| {
477 CascadeError::branch(format!(
478 "Failed to create branch '{}' from commit {}: {}",
479 branch,
480 &commit_hash[..8],
481 e
482 ))
483 })?;
484
485 }
487
488 let entry_id = stack.push_entry(branch.clone(), commit_hash.clone(), message.clone());
490
491 let commit_metadata = CommitMetadata::new(
493 commit_hash.clone(),
494 message,
495 entry_id,
496 stack_id,
497 branch.clone(),
498 source_branch,
499 );
500
501 self.metadata.add_commit(commit_metadata);
503 if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
504 stack_meta.add_branch(branch);
505 stack_meta.add_commit(commit_hash);
506 }
507
508 self.save_to_disk()?;
509
510 Ok(entry_id)
511 }
512
513 pub fn pop_from_stack(&mut self) -> Result<StackEntry> {
515 let stack_id = self
516 .metadata
517 .active_stack_id
518 .ok_or_else(|| CascadeError::config("No active stack"))?;
519
520 let stack = self
521 .stacks
522 .get_mut(&stack_id)
523 .ok_or_else(|| CascadeError::config("Active stack not found"))?;
524
525 let entry = stack
526 .pop_entry()
527 .ok_or_else(|| CascadeError::config("Stack is empty"))?;
528
529 self.metadata.remove_commit(&entry.commit_hash);
531
532 if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
534 stack_meta.remove_commit(&entry.commit_hash);
535 }
537
538 self.save_to_disk()?;
539
540 Ok(entry)
541 }
542
543 pub fn submit_entry(
545 &mut self,
546 stack_id: &Uuid,
547 entry_id: &Uuid,
548 pull_request_id: String,
549 ) -> Result<()> {
550 let stack = self
551 .stacks
552 .get_mut(stack_id)
553 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
554
555 let entry_commit_hash = {
556 let entry = stack
557 .get_entry(entry_id)
558 .ok_or_else(|| CascadeError::config(format!("Entry {entry_id} not found")))?;
559 entry.commit_hash.clone()
560 };
561
562 if !stack.mark_entry_submitted(entry_id, pull_request_id.clone()) {
564 return Err(CascadeError::config(format!(
565 "Failed to mark entry {entry_id} as submitted"
566 )));
567 }
568
569 if let Some(commit_meta) = self.metadata.commits.get_mut(&entry_commit_hash) {
571 commit_meta.mark_submitted(pull_request_id);
572 }
573
574 if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
576 let submitted_count = stack.entries.iter().filter(|e| e.is_submitted).count();
577 let merged_count = stack.entries.iter().filter(|e| e.is_merged).count();
578 stack_meta.update_stats(stack.entries.len(), submitted_count, merged_count);
579 }
580
581 self.save_to_disk()?;
582
583 Ok(())
584 }
585
586 pub fn remove_stack_entry(
588 &mut self,
589 stack_id: &Uuid,
590 entry_id: &Uuid,
591 ) -> Result<Option<StackEntry>> {
592 let stack = match self.stacks.get_mut(stack_id) {
593 Some(stack) => stack,
594 None => return Err(CascadeError::config(format!("Stack {stack_id} not found"))),
595 };
596
597 let entry = match stack.entry_map.get(entry_id) {
598 Some(entry) => entry.clone(),
599 None => return Ok(None),
600 };
601
602 if !entry.children.is_empty() {
603 warn!(
604 "Skipping removal of stack entry {} (branch '{}') because it still has {} child entr{}",
605 entry.id,
606 entry.branch,
607 entry.children.len(),
608 if entry.children.len() == 1 { "y" } else { "ies" }
609 );
610 return Ok(None);
611 }
612
613 stack.entries.retain(|e| e.id != entry.id);
615
616 stack.entry_map.remove(&entry.id);
618
619 if let Some(parent_id) = entry.parent_id {
621 if let Some(parent) = stack.entry_map.get_mut(&parent_id) {
622 parent.children.retain(|child| child != &entry.id);
623 }
624 }
625
626 stack.repair_data_consistency();
628 stack.updated_at = Utc::now();
629
630 self.metadata.remove_commit(&entry.commit_hash);
632 if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
633 stack_meta.remove_commit(&entry.commit_hash);
634 stack_meta.remove_branch(&entry.branch);
635
636 let submitted = stack.entries.iter().filter(|e| e.is_submitted).count();
637 let merged = stack.entries.iter().filter(|e| e.is_merged).count();
638 stack_meta.update_stats(stack.entries.len(), submitted, merged);
639 }
640
641 self.save_to_disk()?;
642
643 Ok(Some(entry))
644 }
645
646 pub fn set_entry_merged(
648 &mut self,
649 stack_id: &Uuid,
650 entry_id: &Uuid,
651 merged: bool,
652 ) -> Result<()> {
653 let stack = self
654 .stacks
655 .get_mut(stack_id)
656 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
657
658 let current_entry = stack
659 .entry_map
660 .get(entry_id)
661 .cloned()
662 .ok_or_else(|| CascadeError::config(format!("Entry {entry_id} not found")))?;
663
664 if current_entry.is_merged == merged {
665 return Ok(());
666 }
667
668 if !stack.mark_entry_merged(entry_id, merged) {
669 return Err(CascadeError::config(format!(
670 "Entry {entry_id} not found in stack {stack_id}"
671 )));
672 }
673
674 if let Some(commit_meta) = self.metadata.commits.get_mut(¤t_entry.commit_hash) {
676 commit_meta.mark_merged(merged);
677 }
678
679 if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
681 let submitted_count = stack.entries.iter().filter(|e| e.is_submitted).count();
682 let merged_count = stack.entries.iter().filter(|e| e.is_merged).count();
683 stack_meta.update_stats(stack.entries.len(), submitted_count, merged_count);
684 }
685
686 self.save_to_disk()?;
687
688 Ok(())
689 }
690
691 pub fn repair_all_stacks(&mut self) -> Result<()> {
693 for stack in self.stacks.values_mut() {
694 stack.repair_data_consistency();
695 }
696 self.save_to_disk()?;
697 Ok(())
698 }
699
700 pub fn get_all_stacks(&self) -> Vec<&Stack> {
702 self.stacks.values().collect()
703 }
704
705 pub fn get_stack_metadata(&self, stack_id: &Uuid) -> Option<&StackMetadata> {
707 self.metadata.get_stack(stack_id)
708 }
709
710 pub fn get_repository_metadata(&self) -> &RepositoryMetadata {
712 &self.metadata
713 }
714
715 pub fn git_repo(&self) -> &GitRepository {
717 &self.repo
718 }
719
720 pub fn repo_path(&self) -> &Path {
722 &self.repo_path
723 }
724
725 pub fn is_in_edit_mode(&self) -> bool {
729 self.metadata
730 .edit_mode
731 .as_ref()
732 .map(|edit_state| edit_state.is_active)
733 .unwrap_or(false)
734 }
735
736 pub fn get_edit_mode_info(&self) -> Option<&super::metadata::EditModeState> {
738 self.metadata.edit_mode.as_ref()
739 }
740
741 pub fn enter_edit_mode(&mut self, stack_id: Uuid, entry_id: Uuid) -> Result<()> {
743 let commit_hash = {
745 let stack = self
746 .get_stack(&stack_id)
747 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
748
749 let entry = stack.get_entry(&entry_id).ok_or_else(|| {
750 CascadeError::config(format!("Entry {entry_id} not found in stack"))
751 })?;
752
753 entry.commit_hash.clone()
754 };
755
756 if self.is_in_edit_mode() {
758 self.exit_edit_mode()?;
759 }
760
761 let edit_state = super::metadata::EditModeState::new(stack_id, entry_id, commit_hash);
763
764 self.metadata.edit_mode = Some(edit_state);
765 self.save_to_disk()?;
766
767 debug!(
768 "Entered edit mode for entry {} in stack {}",
769 entry_id, stack_id
770 );
771 Ok(())
772 }
773
774 pub fn exit_edit_mode(&mut self) -> Result<()> {
776 if !self.is_in_edit_mode() {
777 return Err(CascadeError::config("Not currently in edit mode"));
778 }
779
780 self.metadata.edit_mode = None;
782 self.save_to_disk()?;
783
784 debug!("Exited edit mode");
785 Ok(())
786 }
787
788 pub fn sync_stack(&mut self, stack_id: &Uuid) -> Result<()> {
790 let stack = self
791 .stacks
792 .get_mut(stack_id)
793 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
794
795 if let Err(integrity_error) = stack.validate_git_integrity(&self.repo) {
797 stack.update_status(StackStatus::Corrupted);
798 return Err(CascadeError::branch(format!(
799 "Stack '{}' Git integrity check failed:\n{}",
800 stack.name, integrity_error
801 )));
802 }
803
804 let mut missing_commits = Vec::new();
806 for entry in &stack.entries {
807 if !self.repo.commit_exists(&entry.commit_hash)? {
808 missing_commits.push(entry.commit_hash.clone());
809 }
810 }
811
812 if !missing_commits.is_empty() {
813 stack.update_status(StackStatus::Corrupted);
814 return Err(CascadeError::branch(format!(
815 "Stack {} has missing commits: {}",
816 stack.name,
817 missing_commits.join(", ")
818 )));
819 }
820
821 if !self.repo.branch_exists_or_fetch(&stack.base_branch)? {
823 return Err(CascadeError::branch(format!(
824 "Base branch '{}' does not exist locally or remotely. Check the branch name or switch to a different base.",
825 stack.base_branch
826 )));
827 }
828
829 let _base_hash = self.repo.get_branch_head(&stack.base_branch)?;
830
831 let mut corrupted_entry = None;
833 for entry in &stack.entries {
834 if !self.repo.commit_exists(&entry.commit_hash)? {
835 corrupted_entry = Some((entry.commit_hash.clone(), entry.branch.clone()));
836 break;
837 }
838 }
839
840 if let Some((commit_hash, branch)) = corrupted_entry {
841 stack.update_status(StackStatus::Corrupted);
842 return Err(CascadeError::branch(format!(
843 "Commit {commit_hash} from stack entry '{branch}' no longer exists"
844 )));
845 }
846
847 let needs_sync = if let Some(first_entry) = stack.entries.first() {
849 match self
851 .repo
852 .get_commits_between(&stack.base_branch, &first_entry.commit_hash)
853 {
854 Ok(commits) => !commits.is_empty(), Err(_) => true, }
857 } else {
858 false };
860
861 if needs_sync {
863 stack.update_status(StackStatus::NeedsSync);
864 debug!(
865 "Stack '{}' needs sync - new commits on base branch",
866 stack.name
867 );
868 } else {
869 stack.update_status(StackStatus::Clean);
870 debug!("Stack '{}' is clean", stack.name);
871 }
872
873 if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
875 stack_meta.set_up_to_date(true);
876 }
877
878 self.save_to_disk()?;
879
880 Ok(())
881 }
882
883 pub fn list_stacks(&self) -> Vec<(Uuid, &str, &StackStatus, usize, Option<&str>)> {
885 self.stacks
886 .values()
887 .map(|stack| {
888 (
889 stack.id,
890 stack.name.as_str(),
891 &stack.status,
892 stack.entries.len(),
893 if stack.is_active {
894 Some("active")
895 } else {
896 None
897 },
898 )
899 })
900 .collect()
901 }
902
903 pub fn get_all_stacks_objects(&self) -> Result<Vec<Stack>> {
905 let mut stacks: Vec<Stack> = self.stacks.values().cloned().collect();
906 stacks.sort_by(|a, b| a.name.cmp(&b.name));
907 Ok(stacks)
908 }
909
910 pub fn validate_all(&self) -> Result<()> {
912 for stack in self.stacks.values() {
913 stack.validate().map_err(|e| {
915 CascadeError::config(format!("Stack '{}' validation failed: {}", stack.name, e))
916 })?;
917
918 stack.validate_git_integrity(&self.repo).map_err(|e| {
920 CascadeError::config(format!(
921 "Stack '{}' Git integrity validation failed: {}",
922 stack.name, e
923 ))
924 })?;
925 }
926 Ok(())
927 }
928
929 pub fn validate_stack(&self, stack_id: &Uuid) -> Result<()> {
931 let stack = self
932 .stacks
933 .get(stack_id)
934 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
935
936 stack.validate().map_err(|e| {
938 CascadeError::config(format!("Stack '{}' validation failed: {}", stack.name, e))
939 })?;
940
941 stack.validate_git_integrity(&self.repo).map_err(|e| {
943 CascadeError::config(format!(
944 "Stack '{}' Git integrity validation failed: {}",
945 stack.name, e
946 ))
947 })?;
948
949 Ok(())
950 }
951
952 pub fn save_to_disk(&self) -> Result<()> {
954 if !self.config_dir.exists() {
956 fs::create_dir_all(&self.config_dir).map_err(|e| {
957 CascadeError::config(format!("Failed to create config directory: {e}"))
958 })?;
959 }
960
961 crate::utils::atomic_file::write_json(&self.stacks_file, &self.stacks)?;
963
964 crate::utils::atomic_file::write_json(&self.metadata_file, &self.metadata)?;
966
967 Ok(())
968 }
969
970 fn load_from_disk(&mut self) -> Result<()> {
972 if self.stacks_file.exists() {
974 let stacks_content = fs::read_to_string(&self.stacks_file)
975 .map_err(|e| CascadeError::config(format!("Failed to read stacks file: {e}")))?;
976
977 self.stacks = serde_json::from_str(&stacks_content)
978 .map_err(|e| CascadeError::config(format!("Failed to parse stacks file: {e}")))?;
979 }
980
981 if self.metadata_file.exists() {
983 let metadata_content = fs::read_to_string(&self.metadata_file)
984 .map_err(|e| CascadeError::config(format!("Failed to read metadata file: {e}")))?;
985
986 self.metadata = serde_json::from_str(&metadata_content)
987 .map_err(|e| CascadeError::config(format!("Failed to parse metadata file: {e}")))?;
988 }
989
990 Ok(())
991 }
992
993 pub fn check_for_branch_change(&mut self) -> Result<bool> {
996 let (stack_id, stack_name, stored_branch) = {
998 if let Some(active_stack) = self.get_active_stack() {
999 let stack_id = active_stack.id;
1000 let stack_name = active_stack.name.clone();
1001 let stored_branch = if let Some(stack_meta) = self.metadata.get_stack(&stack_id) {
1002 stack_meta.current_branch.clone()
1003 } else {
1004 None
1005 };
1006 (Some(stack_id), stack_name, stored_branch)
1007 } else {
1008 (None, String::new(), None)
1009 }
1010 };
1011
1012 let Some(stack_id) = stack_id else {
1014 return Ok(true);
1015 };
1016
1017 let current_branch = self.repo.get_current_branch().ok();
1018
1019 if stored_branch.as_ref() != current_branch.as_ref() {
1021 Output::warning("Branch change detected!");
1022 Output::sub_item(format!(
1023 "Stack '{}' was active on: {}",
1024 stack_name,
1025 stored_branch.as_deref().unwrap_or("unknown")
1026 ));
1027 Output::sub_item(format!(
1028 "Current branch: {}",
1029 current_branch.as_deref().unwrap_or("unknown")
1030 ));
1031 Output::spacing();
1032
1033 let options = vec![
1034 format!("Keep stack '{stack_name}' active (continue with stack workflow)"),
1035 "Deactivate stack (use normal Git workflow)".to_string(),
1036 "Switch to a different stack".to_string(),
1037 "Cancel and stay on current workflow".to_string(),
1038 ];
1039
1040 let choice = Select::with_theme(&ColorfulTheme::default())
1041 .with_prompt("What would you like to do?")
1042 .default(0)
1043 .items(&options)
1044 .interact()
1045 .map_err(|e| CascadeError::config(format!("Failed to get user choice: {e}")))?;
1046
1047 match choice {
1048 0 => {
1049 if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
1051 stack_meta.set_current_branch(current_branch);
1052 }
1053 self.save_to_disk()?;
1054 Output::success(format!(
1055 "Continuing with stack '{stack_name}' on current branch"
1056 ));
1057 return Ok(true);
1058 }
1059 1 => {
1060 self.set_active_stack(None)?;
1062 Output::success(format!(
1063 "Deactivated stack '{stack_name}' - using normal Git workflow"
1064 ));
1065 return Ok(false);
1066 }
1067 2 => {
1068 let stacks = self.get_all_stacks();
1070 if stacks.len() <= 1 {
1071 Output::warning("No other stacks available. Deactivating current stack.");
1072 self.set_active_stack(None)?;
1073 return Ok(false);
1074 }
1075
1076 Output::spacing();
1077 Output::info("Available stacks:");
1078 for (i, stack) in stacks.iter().enumerate() {
1079 if stack.id != stack_id {
1080 Output::numbered_item(i + 1, &stack.name);
1081 }
1082 }
1083 let stack_name_input: String = Input::with_theme(&ColorfulTheme::default())
1084 .with_prompt("Enter stack name")
1085 .validate_with(|input: &String| -> std::result::Result<(), &str> {
1086 if input.trim().is_empty() {
1087 Err("Stack name cannot be empty")
1088 } else {
1089 Ok(())
1090 }
1091 })
1092 .interact_text()
1093 .map_err(|e| {
1094 CascadeError::config(format!("Failed to get user input: {e}"))
1095 })?;
1096 let stack_name_input = stack_name_input.trim();
1097
1098 if let Err(e) = self.set_active_stack_by_name(stack_name_input) {
1099 Output::warning(format!("{e}"));
1100 Output::sub_item("Deactivating stack instead.");
1101 self.set_active_stack(None)?;
1102 return Ok(false);
1103 } else {
1104 Output::success(format!("Switched to stack '{stack_name_input}'"));
1105 return Ok(true);
1106 }
1107 }
1108 3 => {
1109 Output::info("Cancelled - no changes made");
1110 return Ok(false);
1111 }
1112 _ => {
1113 Output::info("Invalid choice - no changes made");
1114 return Ok(false);
1115 }
1116 }
1117 }
1118
1119 Ok(true)
1121 }
1122
1123 pub fn handle_branch_modifications(
1126 &mut self,
1127 stack_id: &Uuid,
1128 auto_mode: Option<String>,
1129 ) -> Result<()> {
1130 let stack = self
1131 .stacks
1132 .get_mut(stack_id)
1133 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
1134
1135 debug!("Checking Git integrity for stack '{}'", stack.name);
1136
1137 let mut modifications = Vec::new();
1139 for entry in &stack.entries {
1140 if !self.repo.branch_exists(&entry.branch) {
1141 modifications.push(BranchModification::Missing {
1142 branch: entry.branch.clone(),
1143 entry_id: entry.id,
1144 expected_commit: entry.commit_hash.clone(),
1145 });
1146 } else if let Ok(branch_head) = self.repo.get_branch_head(&entry.branch) {
1147 if branch_head != entry.commit_hash {
1148 let extra_commits = self
1150 .repo
1151 .get_commits_between(&entry.commit_hash, &branch_head)?;
1152 let mut extra_messages = Vec::new();
1153 for commit in &extra_commits {
1154 if let Some(message) = commit.message() {
1155 let first_line =
1156 message.lines().next().unwrap_or("(no message)").to_string();
1157 extra_messages.push(format!(
1158 "{}: {}",
1159 &commit.id().to_string()[..8],
1160 first_line
1161 ));
1162 }
1163 }
1164
1165 modifications.push(BranchModification::ExtraCommits {
1166 branch: entry.branch.clone(),
1167 entry_id: entry.id,
1168 expected_commit: entry.commit_hash.clone(),
1169 actual_commit: branch_head,
1170 extra_commit_count: extra_commits.len(),
1171 extra_commit_messages: extra_messages,
1172 });
1173 }
1174 }
1175 }
1176
1177 if modifications.is_empty() {
1178 return Ok(());
1180 }
1181
1182 println!();
1184 Output::section(format!("Branch modifications detected in '{}'", stack.name));
1185 for (i, modification) in modifications.iter().enumerate() {
1186 match modification {
1187 BranchModification::Missing { branch, .. } => {
1188 Output::numbered_item(i + 1, format!("Branch '{branch}' is missing"));
1189 }
1190 BranchModification::ExtraCommits {
1191 branch,
1192 expected_commit,
1193 actual_commit,
1194 extra_commit_count,
1195 extra_commit_messages,
1196 ..
1197 } => {
1198 println!(
1199 " {}. Branch '{}' has {} extra commit(s)",
1200 i + 1,
1201 branch,
1202 extra_commit_count
1203 );
1204 println!(
1205 " Expected: {} | Actual: {}",
1206 &expected_commit[..8],
1207 &actual_commit[..8]
1208 );
1209
1210 for (j, message) in extra_commit_messages.iter().enumerate() {
1212 match j.cmp(&3) {
1213 std::cmp::Ordering::Less => {
1214 Output::sub_item(format!(" + {message}"));
1215 }
1216 std::cmp::Ordering::Equal => {
1217 Output::sub_item(format!(
1218 " + ... and {} more",
1219 extra_commit_count - 3
1220 ));
1221 break;
1222 }
1223 std::cmp::Ordering::Greater => {
1224 break;
1225 }
1226 }
1227 }
1228 }
1229 }
1230 }
1231 Output::spacing();
1232
1233 if let Some(mode) = auto_mode {
1235 return self.apply_auto_fix(stack_id, &modifications, &mode);
1236 }
1237
1238 let mut handled_count = 0;
1240 let mut skipped_count = 0;
1241 for modification in modifications.iter() {
1242 let was_skipped = self.handle_single_modification(stack_id, modification)?;
1243 if was_skipped {
1244 skipped_count += 1;
1245 } else {
1246 handled_count += 1;
1247 }
1248 }
1249
1250 self.save_to_disk()?;
1251
1252 if skipped_count == 0 {
1254 Output::success("All branch modifications resolved");
1255 } else if handled_count > 0 {
1256 Output::warning(format!(
1257 "Resolved {} modification(s), {} skipped",
1258 handled_count, skipped_count
1259 ));
1260 } else {
1261 Output::warning("All modifications skipped - integrity issues remain");
1262 }
1263
1264 Ok(())
1265 }
1266
1267 fn handle_single_modification(
1270 &mut self,
1271 stack_id: &Uuid,
1272 modification: &BranchModification,
1273 ) -> Result<bool> {
1274 match modification {
1275 BranchModification::Missing {
1276 branch,
1277 expected_commit,
1278 ..
1279 } => {
1280 Output::info(format!("Missing branch '{branch}'"));
1281 Output::sub_item(format!(
1282 "Will create the branch at commit {}",
1283 &expected_commit[..8]
1284 ));
1285
1286 self.repo.create_branch(branch, Some(expected_commit))?;
1287 Output::success(format!("Created branch '{branch}'"));
1288 Ok(false) }
1290
1291 BranchModification::ExtraCommits {
1292 branch,
1293 entry_id,
1294 expected_commit,
1295 extra_commit_count,
1296 ..
1297 } => {
1298 println!();
1299 Output::info(format!(
1300 "Branch '{}' has {} extra commit(s)",
1301 branch, extra_commit_count
1302 ));
1303 let options = vec![
1304 "Incorporate - Update stack entry to include extra commits",
1305 "Split - Create new stack entry for extra commits",
1306 "Reset - Remove extra commits (DESTRUCTIVE)",
1307 "Skip - Leave as-is for now",
1308 ];
1309
1310 let choice = Select::with_theme(&ColorfulTheme::default())
1311 .with_prompt("Choose how to handle extra commits")
1312 .default(0)
1313 .items(&options)
1314 .interact()
1315 .map_err(|e| CascadeError::config(format!("Failed to get user choice: {e}")))?;
1316
1317 match choice {
1318 0 => {
1319 self.incorporate_extra_commits(stack_id, *entry_id, branch)?;
1320 Ok(false) }
1322 1 => {
1323 self.split_extra_commits(stack_id, *entry_id, branch)?;
1324 Ok(false) }
1326 2 => {
1327 self.reset_branch_destructive(branch, expected_commit)?;
1328 Ok(false) }
1330 3 => {
1331 Output::warning(format!("Skipped '{branch}' - integrity issue remains"));
1332 Ok(true) }
1334 _ => {
1335 Output::warning(format!("Invalid choice - skipped '{branch}'"));
1336 Ok(true) }
1338 }
1339 }
1340 }
1341 }
1342
1343 fn apply_auto_fix(
1345 &mut self,
1346 stack_id: &Uuid,
1347 modifications: &[BranchModification],
1348 mode: &str,
1349 ) -> Result<()> {
1350 Output::info(format!("š¤ Applying automatic fix mode: {mode}"));
1351
1352 for modification in modifications {
1353 match (modification, mode) {
1354 (
1355 BranchModification::Missing {
1356 branch,
1357 expected_commit,
1358 ..
1359 },
1360 _,
1361 ) => {
1362 self.repo.create_branch(branch, Some(expected_commit))?;
1363 Output::success(format!("Created missing branch '{branch}'"));
1364 }
1365
1366 (
1367 BranchModification::ExtraCommits {
1368 branch, entry_id, ..
1369 },
1370 "incorporate",
1371 ) => {
1372 self.incorporate_extra_commits(stack_id, *entry_id, branch)?;
1373 }
1374
1375 (
1376 BranchModification::ExtraCommits {
1377 branch, entry_id, ..
1378 },
1379 "split",
1380 ) => {
1381 self.split_extra_commits(stack_id, *entry_id, branch)?;
1382 }
1383
1384 (
1385 BranchModification::ExtraCommits {
1386 branch,
1387 expected_commit,
1388 ..
1389 },
1390 "reset",
1391 ) => {
1392 self.reset_branch_destructive(branch, expected_commit)?;
1393 }
1394
1395 _ => {
1396 return Err(CascadeError::config(format!(
1397 "Unknown auto-fix mode '{mode}'. Use: incorporate, split, reset"
1398 )));
1399 }
1400 }
1401 }
1402
1403 self.save_to_disk()?;
1404 Output::success(format!("Auto-fix completed for mode: {mode}"));
1405 Ok(())
1406 }
1407
1408 fn incorporate_extra_commits(
1410 &mut self,
1411 stack_id: &Uuid,
1412 entry_id: Uuid,
1413 branch: &str,
1414 ) -> Result<()> {
1415 let stack = self
1416 .stacks
1417 .get_mut(stack_id)
1418 .ok_or_else(|| CascadeError::config(format!("Stack not found: {}", stack_id)))?;
1419
1420 let entry_info = stack
1422 .entries
1423 .iter()
1424 .find(|e| e.id == entry_id)
1425 .map(|e| (e.commit_hash.clone(), e.id));
1426
1427 if let Some((old_commit_hash, entry_id)) = entry_info {
1428 let new_head = self.repo.get_branch_head(branch)?;
1429 let old_commit = old_commit_hash[..8].to_string();
1430
1431 let extra_commits = self.repo.get_commits_between(&old_commit_hash, &new_head)?;
1433
1434 stack
1438 .update_entry_commit_hash(&entry_id, new_head.clone())
1439 .map_err(CascadeError::config)?;
1440
1441 Output::success(format!(
1442 "Incorporated {} commit(s) into entry '{}'",
1443 extra_commits.len(),
1444 &new_head[..8]
1445 ));
1446 Output::sub_item(format!("Updated: {} -> {}", old_commit, &new_head[..8]));
1447 }
1448
1449 Ok(())
1450 }
1451
1452 fn split_extra_commits(&mut self, stack_id: &Uuid, entry_id: Uuid, branch: &str) -> Result<()> {
1454 let stack = self
1455 .stacks
1456 .get_mut(stack_id)
1457 .ok_or_else(|| CascadeError::config(format!("Stack not found: {}", stack_id)))?;
1458 let new_head = self.repo.get_branch_head(branch)?;
1459
1460 let entry_position = stack
1462 .entries
1463 .iter()
1464 .position(|e| e.id == entry_id)
1465 .ok_or_else(|| CascadeError::config("Entry not found in stack"))?;
1466
1467 let base_name = branch.trim_end_matches(|c: char| c.is_ascii_digit() || c == '-');
1469 let new_branch = format!("{base_name}-continued");
1470
1471 self.repo.create_branch(&new_branch, Some(&new_head))?;
1473
1474 let original_entry = &stack.entries[entry_position];
1476 let original_commit_hash = original_entry.commit_hash.clone(); let extra_commits = self
1478 .repo
1479 .get_commits_between(&original_commit_hash, &new_head)?;
1480
1481 let mut extra_messages = Vec::new();
1483 for commit in &extra_commits {
1484 if let Some(message) = commit.message() {
1485 let first_line = message.lines().next().unwrap_or("").to_string();
1486 extra_messages.push(first_line);
1487 }
1488 }
1489
1490 let new_message = if extra_messages.len() == 1 {
1491 extra_messages[0].clone()
1492 } else {
1493 format!("Combined changes:\n⢠{}", extra_messages.join("\n⢠"))
1494 };
1495
1496 let now = Utc::now();
1498 let new_entry = crate::stack::StackEntry {
1499 id: uuid::Uuid::new_v4(),
1500 branch: new_branch.clone(),
1501 commit_hash: new_head,
1502 message: new_message,
1503 parent_id: Some(entry_id), children: Vec::new(),
1505 created_at: now,
1506 updated_at: now,
1507 is_submitted: false,
1508 pull_request_id: None,
1509 is_synced: false,
1510 is_merged: false,
1511 };
1512
1513 stack.entries.insert(entry_position + 1, new_entry);
1515
1516 self.repo
1518 .reset_branch_to_commit(branch, &original_commit_hash)?;
1519
1520 println!(
1521 " ā
Split {} commit(s) into new entry '{}'",
1522 extra_commits.len(),
1523 new_branch
1524 );
1525 println!(" Original branch '{branch}' reset to expected commit");
1526
1527 Ok(())
1528 }
1529
1530 fn reset_branch_destructive(&self, branch: &str, expected_commit: &str) -> Result<()> {
1532 self.repo.reset_branch_to_commit(branch, expected_commit)?;
1533 Output::warning(format!(
1534 "Reset branch '{}' to {} (extra commits lost)",
1535 branch,
1536 &expected_commit[..8]
1537 ));
1538 Ok(())
1539 }
1540}
1541
1542#[cfg(test)]
1543mod tests {
1544 use super::*;
1545 use std::process::Command;
1546 use tempfile::TempDir;
1547
1548 fn create_test_repo() -> (TempDir, PathBuf) {
1549 let temp_dir = TempDir::new().unwrap();
1550 let repo_path = temp_dir.path().to_path_buf();
1551
1552 Command::new("git")
1554 .args(["init"])
1555 .current_dir(&repo_path)
1556 .output()
1557 .unwrap();
1558
1559 Command::new("git")
1561 .args(["config", "user.name", "Test User"])
1562 .current_dir(&repo_path)
1563 .output()
1564 .unwrap();
1565
1566 Command::new("git")
1567 .args(["config", "user.email", "test@example.com"])
1568 .current_dir(&repo_path)
1569 .output()
1570 .unwrap();
1571
1572 std::fs::write(repo_path.join("README.md"), "# Test Repo").unwrap();
1574 Command::new("git")
1575 .args(["add", "."])
1576 .current_dir(&repo_path)
1577 .output()
1578 .unwrap();
1579
1580 Command::new("git")
1581 .args(["commit", "-m", "Initial commit"])
1582 .current_dir(&repo_path)
1583 .output()
1584 .unwrap();
1585
1586 crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
1588 .unwrap();
1589
1590 (temp_dir, repo_path)
1591 }
1592
1593 #[test]
1594 fn test_create_stack_manager() {
1595 let (_temp_dir, repo_path) = create_test_repo();
1596 let manager = StackManager::new(&repo_path).unwrap();
1597
1598 assert_eq!(manager.stacks.len(), 0);
1599 assert!(manager.get_active_stack().is_none());
1600 }
1601
1602 #[test]
1603 fn test_create_and_manage_stack() {
1604 let (_temp_dir, repo_path) = create_test_repo();
1605 let mut manager = StackManager::new(&repo_path).unwrap();
1606
1607 let stack_id = manager
1609 .create_stack(
1610 "test-stack".to_string(),
1611 None, Some("Test stack description".to_string()),
1613 )
1614 .unwrap();
1615
1616 assert_eq!(manager.stacks.len(), 1);
1618 let stack = manager.get_stack(&stack_id).unwrap();
1619 assert_eq!(stack.name, "test-stack");
1620 assert!(!stack.base_branch.is_empty());
1622 assert!(stack.is_active);
1623
1624 let active = manager.get_active_stack().unwrap();
1626 assert_eq!(active.id, stack_id);
1627
1628 let found = manager.get_stack_by_name("test-stack").unwrap();
1630 assert_eq!(found.id, stack_id);
1631 }
1632
1633 #[test]
1634 fn test_stack_persistence() {
1635 let (_temp_dir, repo_path) = create_test_repo();
1636
1637 let stack_id = {
1638 let mut manager = StackManager::new(&repo_path).unwrap();
1639 manager
1640 .create_stack("persistent-stack".to_string(), None, None)
1641 .unwrap()
1642 };
1643
1644 let manager = StackManager::new(&repo_path).unwrap();
1646 assert_eq!(manager.stacks.len(), 1);
1647 let stack = manager.get_stack(&stack_id).unwrap();
1648 assert_eq!(stack.name, "persistent-stack");
1649 }
1650
1651 #[test]
1652 fn test_multiple_stacks() {
1653 let (_temp_dir, repo_path) = create_test_repo();
1654 let mut manager = StackManager::new(&repo_path).unwrap();
1655
1656 let stack1_id = manager
1657 .create_stack("stack-1".to_string(), None, None)
1658 .unwrap();
1659 let stack2_id = manager
1660 .create_stack("stack-2".to_string(), None, None)
1661 .unwrap();
1662
1663 assert_eq!(manager.stacks.len(), 2);
1664
1665 assert!(!manager.get_stack(&stack1_id).unwrap().is_active);
1667 assert!(manager.get_stack(&stack2_id).unwrap().is_active);
1668
1669 manager.set_active_stack(Some(stack2_id)).unwrap();
1671 assert!(!manager.get_stack(&stack1_id).unwrap().is_active);
1672 assert!(manager.get_stack(&stack2_id).unwrap().is_active);
1673 }
1674
1675 #[test]
1676 fn test_delete_stack() {
1677 let (_temp_dir, repo_path) = create_test_repo();
1678 let mut manager = StackManager::new(&repo_path).unwrap();
1679
1680 let stack_id = manager
1681 .create_stack("to-delete".to_string(), None, None)
1682 .unwrap();
1683 assert_eq!(manager.stacks.len(), 1);
1684
1685 let deleted = manager.delete_stack(&stack_id).unwrap();
1686 assert_eq!(deleted.name, "to-delete");
1687 assert_eq!(manager.stacks.len(), 0);
1688 assert!(manager.get_active_stack().is_none());
1689 }
1690
1691 #[test]
1692 fn test_validation() {
1693 let (_temp_dir, repo_path) = create_test_repo();
1694 let mut manager = StackManager::new(&repo_path).unwrap();
1695
1696 manager
1697 .create_stack("valid-stack".to_string(), None, None)
1698 .unwrap();
1699
1700 assert!(manager.validate_all().is_ok());
1702 }
1703
1704 #[test]
1705 fn test_duplicate_commit_message_detection() {
1706 let (_temp_dir, repo_path) = create_test_repo();
1707 let mut manager = StackManager::new(&repo_path).unwrap();
1708
1709 manager
1711 .create_stack("test-stack".to_string(), None, None)
1712 .unwrap();
1713
1714 std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1716 Command::new("git")
1717 .args(["add", "file1.txt"])
1718 .current_dir(&repo_path)
1719 .output()
1720 .unwrap();
1721
1722 Command::new("git")
1723 .args(["commit", "-m", "Add authentication feature"])
1724 .current_dir(&repo_path)
1725 .output()
1726 .unwrap();
1727
1728 let commit1_hash = Command::new("git")
1729 .args(["rev-parse", "HEAD"])
1730 .current_dir(&repo_path)
1731 .output()
1732 .unwrap();
1733 let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1734 .trim()
1735 .to_string();
1736
1737 let entry1_id = manager
1739 .push_to_stack(
1740 "feature/auth".to_string(),
1741 commit1_hash,
1742 "Add authentication feature".to_string(),
1743 "main".to_string(),
1744 )
1745 .unwrap();
1746
1747 assert!(manager
1749 .get_active_stack()
1750 .unwrap()
1751 .get_entry(&entry1_id)
1752 .is_some());
1753
1754 std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1756 Command::new("git")
1757 .args(["add", "file2.txt"])
1758 .current_dir(&repo_path)
1759 .output()
1760 .unwrap();
1761
1762 Command::new("git")
1763 .args(["commit", "-m", "Different commit message"])
1764 .current_dir(&repo_path)
1765 .output()
1766 .unwrap();
1767
1768 let commit2_hash = Command::new("git")
1769 .args(["rev-parse", "HEAD"])
1770 .current_dir(&repo_path)
1771 .output()
1772 .unwrap();
1773 let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1774 .trim()
1775 .to_string();
1776
1777 let result = manager.push_to_stack(
1779 "feature/auth2".to_string(),
1780 commit2_hash.clone(),
1781 "Add authentication feature".to_string(), "main".to_string(),
1783 );
1784
1785 assert!(result.is_err());
1787 let error = result.unwrap_err();
1788 assert!(matches!(error, CascadeError::Validation(_)));
1789
1790 let error_msg = error.to_string();
1792 assert!(error_msg.contains("Duplicate commit message"));
1793 assert!(error_msg.contains("Add authentication feature"));
1794 assert!(error_msg.contains("š” Consider using a more specific message"));
1795
1796 let entry2_id = manager
1798 .push_to_stack(
1799 "feature/auth2".to_string(),
1800 commit2_hash,
1801 "Add authentication validation".to_string(), "main".to_string(),
1803 )
1804 .unwrap();
1805
1806 let stack = manager.get_active_stack().unwrap();
1808 assert_eq!(stack.entries.len(), 2);
1809 assert!(stack.get_entry(&entry1_id).is_some());
1810 assert!(stack.get_entry(&entry2_id).is_some());
1811 }
1812
1813 #[test]
1814 fn test_duplicate_message_with_different_case() {
1815 let (_temp_dir, repo_path) = create_test_repo();
1816 let mut manager = StackManager::new(&repo_path).unwrap();
1817
1818 manager
1819 .create_stack("test-stack".to_string(), None, None)
1820 .unwrap();
1821
1822 std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1824 Command::new("git")
1825 .args(["add", "file1.txt"])
1826 .current_dir(&repo_path)
1827 .output()
1828 .unwrap();
1829
1830 Command::new("git")
1831 .args(["commit", "-m", "fix bug"])
1832 .current_dir(&repo_path)
1833 .output()
1834 .unwrap();
1835
1836 let commit1_hash = Command::new("git")
1837 .args(["rev-parse", "HEAD"])
1838 .current_dir(&repo_path)
1839 .output()
1840 .unwrap();
1841 let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1842 .trim()
1843 .to_string();
1844
1845 manager
1846 .push_to_stack(
1847 "feature/fix1".to_string(),
1848 commit1_hash,
1849 "fix bug".to_string(),
1850 "main".to_string(),
1851 )
1852 .unwrap();
1853
1854 std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1856 Command::new("git")
1857 .args(["add", "file2.txt"])
1858 .current_dir(&repo_path)
1859 .output()
1860 .unwrap();
1861
1862 Command::new("git")
1863 .args(["commit", "-m", "Fix Bug"])
1864 .current_dir(&repo_path)
1865 .output()
1866 .unwrap();
1867
1868 let commit2_hash = Command::new("git")
1869 .args(["rev-parse", "HEAD"])
1870 .current_dir(&repo_path)
1871 .output()
1872 .unwrap();
1873 let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1874 .trim()
1875 .to_string();
1876
1877 let result = manager.push_to_stack(
1879 "feature/fix2".to_string(),
1880 commit2_hash,
1881 "Fix Bug".to_string(), "main".to_string(),
1883 );
1884
1885 assert!(result.is_ok());
1887 }
1888
1889 #[test]
1890 fn test_duplicate_message_across_different_stacks() {
1891 let (_temp_dir, repo_path) = create_test_repo();
1892 let mut manager = StackManager::new(&repo_path).unwrap();
1893
1894 let stack1_id = manager
1896 .create_stack("stack1".to_string(), None, None)
1897 .unwrap();
1898
1899 std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1900 Command::new("git")
1901 .args(["add", "file1.txt"])
1902 .current_dir(&repo_path)
1903 .output()
1904 .unwrap();
1905
1906 Command::new("git")
1907 .args(["commit", "-m", "shared message"])
1908 .current_dir(&repo_path)
1909 .output()
1910 .unwrap();
1911
1912 let commit1_hash = Command::new("git")
1913 .args(["rev-parse", "HEAD"])
1914 .current_dir(&repo_path)
1915 .output()
1916 .unwrap();
1917 let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1918 .trim()
1919 .to_string();
1920
1921 manager
1922 .push_to_stack(
1923 "feature/shared1".to_string(),
1924 commit1_hash,
1925 "shared message".to_string(),
1926 "main".to_string(),
1927 )
1928 .unwrap();
1929
1930 let stack2_id = manager
1932 .create_stack("stack2".to_string(), None, None)
1933 .unwrap();
1934
1935 manager.set_active_stack(Some(stack2_id)).unwrap();
1937
1938 std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1940 Command::new("git")
1941 .args(["add", "file2.txt"])
1942 .current_dir(&repo_path)
1943 .output()
1944 .unwrap();
1945
1946 Command::new("git")
1947 .args(["commit", "-m", "shared message"])
1948 .current_dir(&repo_path)
1949 .output()
1950 .unwrap();
1951
1952 let commit2_hash = Command::new("git")
1953 .args(["rev-parse", "HEAD"])
1954 .current_dir(&repo_path)
1955 .output()
1956 .unwrap();
1957 let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1958 .trim()
1959 .to_string();
1960
1961 let result = manager.push_to_stack(
1963 "feature/shared2".to_string(),
1964 commit2_hash,
1965 "shared message".to_string(), "main".to_string(),
1967 );
1968
1969 assert!(result.is_ok());
1971
1972 let stack1 = manager.get_stack(&stack1_id).unwrap();
1974 let stack2 = manager.get_stack(&stack2_id).unwrap();
1975
1976 assert_eq!(stack1.entries.len(), 1);
1977 assert_eq!(stack2.entries.len(), 1);
1978 assert_eq!(stack1.entries[0].message, "shared message");
1979 assert_eq!(stack2.entries[0].message, "shared message");
1980 }
1981
1982 #[test]
1983 fn test_duplicate_after_pop() {
1984 let (_temp_dir, repo_path) = create_test_repo();
1985 let mut manager = StackManager::new(&repo_path).unwrap();
1986
1987 manager
1988 .create_stack("test-stack".to_string(), None, None)
1989 .unwrap();
1990
1991 std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1993 Command::new("git")
1994 .args(["add", "file1.txt"])
1995 .current_dir(&repo_path)
1996 .output()
1997 .unwrap();
1998
1999 Command::new("git")
2000 .args(["commit", "-m", "temporary message"])
2001 .current_dir(&repo_path)
2002 .output()
2003 .unwrap();
2004
2005 let commit1_hash = Command::new("git")
2006 .args(["rev-parse", "HEAD"])
2007 .current_dir(&repo_path)
2008 .output()
2009 .unwrap();
2010 let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
2011 .trim()
2012 .to_string();
2013
2014 manager
2015 .push_to_stack(
2016 "feature/temp".to_string(),
2017 commit1_hash,
2018 "temporary message".to_string(),
2019 "main".to_string(),
2020 )
2021 .unwrap();
2022
2023 let popped = manager.pop_from_stack().unwrap();
2025 assert_eq!(popped.message, "temporary message");
2026
2027 std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
2029 Command::new("git")
2030 .args(["add", "file2.txt"])
2031 .current_dir(&repo_path)
2032 .output()
2033 .unwrap();
2034
2035 Command::new("git")
2036 .args(["commit", "-m", "temporary message"])
2037 .current_dir(&repo_path)
2038 .output()
2039 .unwrap();
2040
2041 let commit2_hash = Command::new("git")
2042 .args(["rev-parse", "HEAD"])
2043 .current_dir(&repo_path)
2044 .output()
2045 .unwrap();
2046 let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
2047 .trim()
2048 .to_string();
2049
2050 let result = manager.push_to_stack(
2052 "feature/temp2".to_string(),
2053 commit2_hash,
2054 "temporary message".to_string(),
2055 "main".to_string(),
2056 );
2057
2058 assert!(result.is_ok());
2059 }
2060}