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, 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.save_to_disk()?;
158
159 Ok(stack_id)
160 }
161
162 pub fn get_stack(&self, stack_id: &Uuid) -> Option<&Stack> {
164 self.stacks.get(stack_id)
165 }
166
167 pub fn get_stack_mut(&mut self, stack_id: &Uuid) -> Option<&mut Stack> {
169 self.stacks.get_mut(stack_id)
170 }
171
172 pub fn get_stack_by_name(&self, name: &str) -> Option<&Stack> {
174 if let Some(metadata) = self.metadata.find_stack_by_name(name) {
175 self.stacks.get(&metadata.stack_id)
176 } else {
177 None
178 }
179 }
180
181 pub fn get_stack_by_name_mut(&mut self, name: &str) -> Option<&mut Stack> {
183 if let Some(metadata) = self.metadata.find_stack_by_name(name) {
184 self.stacks.get_mut(&metadata.stack_id)
185 } else {
186 None
187 }
188 }
189
190 pub fn update_stack_working_branch(&mut self, name: &str, branch: String) -> Result<()> {
192 if let Some(stack) = self.get_stack_by_name_mut(name) {
193 stack.working_branch = Some(branch);
194 self.save_to_disk()?;
195 Ok(())
196 } else {
197 Err(CascadeError::config(format!("Stack '{name}' not found")))
198 }
199 }
200
201 fn find_stack_id_for_branch(&self, branch: &str) -> Option<Uuid> {
204 for stack in self.stacks.values() {
205 if stack.working_branch.as_deref() == Some(branch) {
206 return Some(stack.id);
207 }
208 }
209 for stack in self.stacks.values() {
210 for entry in &stack.entries {
211 if entry.branch == branch {
212 return Some(stack.id);
213 }
214 }
215 }
216 None
217 }
218
219 fn get_active_stack_id(&self) -> Option<Uuid> {
221 let current_branch = self.repo.get_current_branch().ok()?;
222 self.find_stack_id_for_branch(¤t_branch)
223 }
224
225 pub fn get_active_stack(&self) -> Option<&Stack> {
227 let stack_id = self.get_active_stack_id()?;
228 self.stacks.get(&stack_id)
229 }
230
231 pub fn get_active_stack_mut(&mut self) -> Option<&mut Stack> {
233 let stack_id = self.get_active_stack_id()?;
234 self.stacks.get_mut(&stack_id)
235 }
236
237 pub fn checkout_stack_branch(&self, stack_id: &Uuid) -> Result<()> {
239 let stack = self
240 .stacks
241 .get(stack_id)
242 .ok_or_else(|| CascadeError::config(format!("Stack with ID {stack_id} not found")))?;
243
244 let target_branch = stack
245 .working_branch
246 .as_deref()
247 .or_else(|| stack.entries.last().map(|e| e.branch.as_str()))
248 .ok_or_else(|| {
249 CascadeError::config(format!(
250 "Stack '{}' has no working branch or entries",
251 stack.name
252 ))
253 })?
254 .to_string();
255
256 self.repo.checkout_branch(&target_branch)?;
257 Ok(())
258 }
259
260 pub fn delete_stack(&mut self, stack_id: &Uuid) -> Result<Stack> {
262 let stack = self
263 .stacks
264 .remove(stack_id)
265 .ok_or_else(|| CascadeError::config(format!("Stack with ID {stack_id} not found")))?;
266
267 self.metadata.remove_stack(stack_id);
269
270 let stack_commits: Vec<String> = self
272 .metadata
273 .commits
274 .values()
275 .filter(|commit| &commit.stack_id == stack_id)
276 .map(|commit| commit.hash.clone())
277 .collect();
278
279 for commit_hash in stack_commits {
280 self.metadata.remove_commit(&commit_hash);
281 }
282
283 self.save_to_disk()?;
284
285 Ok(stack)
286 }
287
288 pub fn push_to_stack(
290 &mut self,
291 branch: String,
292 commit_hash: String,
293 message: String,
294 source_branch: String,
295 ) -> Result<Uuid> {
296 let stack_id = self.get_active_stack_id().ok_or_else(|| {
297 CascadeError::config("No active stack (current branch doesn't belong to any stack)")
298 })?;
299
300 let mut reconciled = false;
303 {
304 let stack = self
305 .stacks
306 .get_mut(&stack_id)
307 .ok_or_else(|| CascadeError::config("Active stack not found"))?;
308
309 if !stack.entries.is_empty() {
310 let mut updates = Vec::new();
312 for entry in &stack.entries {
313 if let Ok(current_commit) = self.repo.get_branch_head(&entry.branch) {
314 if entry.commit_hash != current_commit {
315 debug!(
316 "Reconciling stale metadata for '{}': updating hash from {} to {} (current branch HEAD)",
317 entry.branch,
318 &entry.commit_hash[..8],
319 ¤t_commit[..8]
320 );
321 updates.push((entry.id, current_commit));
322 }
323 }
324 }
325
326 for (entry_id, new_hash) in updates {
328 stack
329 .update_entry_commit_hash(&entry_id, new_hash)
330 .map_err(CascadeError::config)?;
331 reconciled = true;
332 }
333 }
334 } if reconciled {
338 debug!("Saving reconciled metadata before validation");
339 self.save_to_disk()?;
340 }
341
342 let stack = self
344 .stacks
345 .get_mut(&stack_id)
346 .ok_or_else(|| CascadeError::config("Active stack not found"))?;
347
348 if !stack.entries.is_empty() {
349 if let Err(integrity_error) = stack.validate_git_integrity(&self.repo) {
350 return Err(CascadeError::validation(format!(
351 "Git integrity validation failed:\n{}\n\n\
352 Fix the stack integrity issues first using 'ca stack validate {}' for details.",
353 integrity_error, stack.name
354 )));
355 }
356 }
357
358 if !self.repo.commit_exists(&commit_hash)? {
360 return Err(CascadeError::branch(format!(
361 "Commit {commit_hash} does not exist"
362 )));
363 }
364
365 let message_trimmed = message.trim();
368 if let Some(duplicate_entry) = stack
369 .entries
370 .iter()
371 .find(|entry| entry.message.trim() == message_trimmed)
372 {
373 return Err(CascadeError::validation(format!(
374 "Duplicate commit message in stack: \"{}\"\n\n\
375 This message already exists in entry {} (commit: {})\n\n\
376 š” Consider using a more specific message:\n\
377 ⢠Add context: \"{} - add validation\"\n\
378 ⢠Be more specific: \"Fix user authentication timeout bug\"\n\
379 ⢠Or amend the previous commit: git commit --amend",
380 message_trimmed,
381 duplicate_entry.id,
382 &duplicate_entry.commit_hash[..8],
383 message_trimmed
384 )));
385 }
386
387 if stack.entries.is_empty() {
392 let current_branch = self.repo.get_current_branch()?;
393
394 if stack.working_branch.is_none() && current_branch != stack.base_branch {
396 stack.working_branch = Some(current_branch.clone());
397 tracing::debug!(
398 "Set working branch for stack '{}' to '{}'",
399 stack.name,
400 current_branch
401 );
402 }
403
404 if current_branch != stack.base_branch && current_branch != "HEAD" {
405 let base_exists = self.repo.branch_exists(&stack.base_branch);
407 let current_is_feature = current_branch.starts_with("feature/")
408 || current_branch.starts_with("fix/")
409 || current_branch.starts_with("chore/")
410 || current_branch.contains("feature")
411 || current_branch.contains("fix");
412
413 if base_exists && current_is_feature {
414 tracing::debug!(
415 "First commit detected: updating stack '{}' base branch from '{}' to '{}'",
416 stack.name,
417 stack.base_branch,
418 current_branch
419 );
420
421 Output::info("Smart Base Branch Update:");
422 Output::sub_item(format!(
423 "Stack '{}' was created with base '{}'",
424 stack.name, stack.base_branch
425 ));
426 Output::sub_item(format!(
427 "You're now working on feature branch '{current_branch}'"
428 ));
429 Output::sub_item("Updating stack base branch to match your workflow");
430
431 stack.base_branch = current_branch.clone();
433
434 if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
436 stack_meta.base_branch = current_branch.clone();
437 stack_meta.set_current_branch(Some(current_branch.clone()));
438 }
439
440 println!(
441 " ā
Stack '{}' base branch updated to '{current_branch}'",
442 stack.name
443 );
444 }
445 }
446 }
447
448 if self.repo.branch_exists(&branch) {
451 self.repo
455 .update_branch_to_commit(&branch, &commit_hash)
456 .map_err(|e| {
457 CascadeError::branch(format!(
458 "Failed to update existing branch '{}' to commit {}: {}",
459 branch,
460 &commit_hash[..8],
461 e
462 ))
463 })?;
464 } else {
465 self.repo
467 .create_branch(&branch, Some(&commit_hash))
468 .map_err(|e| {
469 CascadeError::branch(format!(
470 "Failed to create branch '{}' from commit {}: {}",
471 branch,
472 &commit_hash[..8],
473 e
474 ))
475 })?;
476
477 }
479
480 let entry_id = stack.push_entry(branch.clone(), commit_hash.clone(), message.clone());
482
483 let commit_metadata = CommitMetadata::new(
485 commit_hash.clone(),
486 message,
487 entry_id,
488 stack_id,
489 branch.clone(),
490 source_branch,
491 );
492
493 self.metadata.add_commit(commit_metadata);
495 if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
496 stack_meta.add_branch(branch);
497 stack_meta.add_commit(commit_hash);
498 }
499
500 self.save_to_disk()?;
501
502 Ok(entry_id)
503 }
504
505 pub fn pop_from_stack(&mut self) -> Result<StackEntry> {
507 let stack_id = self.get_active_stack_id().ok_or_else(|| {
508 CascadeError::config("No active stack (current branch doesn't belong to any stack)")
509 })?;
510
511 let stack = self
512 .stacks
513 .get_mut(&stack_id)
514 .ok_or_else(|| CascadeError::config("Active stack not found"))?;
515
516 let entry = stack
517 .pop_entry()
518 .ok_or_else(|| CascadeError::config("Stack is empty"))?;
519
520 self.metadata.remove_commit(&entry.commit_hash);
522
523 if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
525 stack_meta.remove_commit(&entry.commit_hash);
526 }
528
529 self.save_to_disk()?;
530
531 Ok(entry)
532 }
533
534 pub fn submit_entry(
536 &mut self,
537 stack_id: &Uuid,
538 entry_id: &Uuid,
539 pull_request_id: String,
540 ) -> Result<()> {
541 let stack = self
542 .stacks
543 .get_mut(stack_id)
544 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
545
546 let entry_commit_hash = {
547 let entry = stack
548 .get_entry(entry_id)
549 .ok_or_else(|| CascadeError::config(format!("Entry {entry_id} not found")))?;
550 entry.commit_hash.clone()
551 };
552
553 if !stack.mark_entry_submitted(entry_id, pull_request_id.clone()) {
555 return Err(CascadeError::config(format!(
556 "Failed to mark entry {entry_id} as submitted"
557 )));
558 }
559
560 if let Some(commit_meta) = self.metadata.commits.get_mut(&entry_commit_hash) {
562 commit_meta.mark_submitted(pull_request_id);
563 }
564
565 if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
567 let submitted_count = stack.entries.iter().filter(|e| e.is_submitted).count();
568 let merged_count = stack.entries.iter().filter(|e| e.is_merged).count();
569 stack_meta.update_stats(stack.entries.len(), submitted_count, merged_count);
570 }
571
572 self.save_to_disk()?;
573
574 Ok(())
575 }
576
577 pub fn remove_stack_entry(
579 &mut self,
580 stack_id: &Uuid,
581 entry_id: &Uuid,
582 ) -> Result<Option<StackEntry>> {
583 let stack = match self.stacks.get_mut(stack_id) {
584 Some(stack) => stack,
585 None => return Err(CascadeError::config(format!("Stack {stack_id} not found"))),
586 };
587
588 let entry = match stack.entry_map.get(entry_id) {
589 Some(entry) => entry.clone(),
590 None => return Ok(None),
591 };
592
593 if !entry.children.is_empty() {
594 warn!(
595 "Skipping removal of stack entry {} (branch '{}') because it still has {} child entr{}",
596 entry.id,
597 entry.branch,
598 entry.children.len(),
599 if entry.children.len() == 1 { "y" } else { "ies" }
600 );
601 return Ok(None);
602 }
603
604 stack.entries.retain(|e| e.id != entry.id);
606
607 stack.entry_map.remove(&entry.id);
609
610 if let Some(parent_id) = entry.parent_id {
612 if let Some(parent) = stack.entry_map.get_mut(&parent_id) {
613 parent.children.retain(|child| child != &entry.id);
614 }
615 }
616
617 stack.repair_data_consistency();
619 stack.updated_at = Utc::now();
620
621 self.metadata.remove_commit(&entry.commit_hash);
623 if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
624 stack_meta.remove_commit(&entry.commit_hash);
625 stack_meta.remove_branch(&entry.branch);
626
627 let submitted = stack.entries.iter().filter(|e| e.is_submitted).count();
628 let merged = stack.entries.iter().filter(|e| e.is_merged).count();
629 stack_meta.update_stats(stack.entries.len(), submitted, merged);
630 }
631
632 self.save_to_disk()?;
633
634 Ok(Some(entry))
635 }
636
637 pub fn remove_stack_entry_at(
639 &mut self,
640 stack_id: &Uuid,
641 index: usize,
642 ) -> Result<Option<StackEntry>> {
643 let stack = match self.stacks.get_mut(stack_id) {
644 Some(stack) => stack,
645 None => return Err(CascadeError::config(format!("Stack {stack_id} not found"))),
646 };
647
648 let entry = match stack.remove_entry_at(index) {
649 Some(entry) => entry,
650 None => return Ok(None),
651 };
652
653 self.metadata.remove_commit(&entry.commit_hash);
655 if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
656 stack_meta.remove_commit(&entry.commit_hash);
657 stack_meta.remove_branch(&entry.branch);
658
659 let submitted = stack.entries.iter().filter(|e| e.is_submitted).count();
660 let merged = stack.entries.iter().filter(|e| e.is_merged).count();
661 stack_meta.update_stats(stack.entries.len(), submitted, merged);
662 }
663
664 self.save_to_disk()?;
665
666 Ok(Some(entry))
667 }
668
669 pub fn set_entry_merged(
671 &mut self,
672 stack_id: &Uuid,
673 entry_id: &Uuid,
674 merged: bool,
675 ) -> Result<()> {
676 let stack = self
677 .stacks
678 .get_mut(stack_id)
679 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
680
681 let current_entry = stack
682 .entry_map
683 .get(entry_id)
684 .cloned()
685 .ok_or_else(|| CascadeError::config(format!("Entry {entry_id} not found")))?;
686
687 if current_entry.is_merged == merged {
688 return Ok(());
689 }
690
691 if !stack.mark_entry_merged(entry_id, merged) {
692 return Err(CascadeError::config(format!(
693 "Entry {entry_id} not found in stack {stack_id}"
694 )));
695 }
696
697 if let Some(commit_meta) = self.metadata.commits.get_mut(¤t_entry.commit_hash) {
699 commit_meta.mark_merged(merged);
700 }
701
702 if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
704 let submitted_count = stack.entries.iter().filter(|e| e.is_submitted).count();
705 let merged_count = stack.entries.iter().filter(|e| e.is_merged).count();
706 stack_meta.update_stats(stack.entries.len(), submitted_count, merged_count);
707 }
708
709 self.save_to_disk()?;
710
711 Ok(())
712 }
713
714 pub fn repair_all_stacks(&mut self) -> Result<()> {
716 for stack in self.stacks.values_mut() {
717 stack.repair_data_consistency();
718 }
719 self.save_to_disk()?;
720 Ok(())
721 }
722
723 pub fn get_all_stacks(&self) -> Vec<&Stack> {
725 self.stacks.values().collect()
726 }
727
728 pub fn get_stack_metadata(&self, stack_id: &Uuid) -> Option<&StackMetadata> {
730 self.metadata.get_stack(stack_id)
731 }
732
733 pub fn get_repository_metadata(&self) -> &RepositoryMetadata {
735 &self.metadata
736 }
737
738 pub fn git_repo(&self) -> &GitRepository {
740 &self.repo
741 }
742
743 pub fn repo_path(&self) -> &Path {
745 &self.repo_path
746 }
747
748 pub fn is_in_edit_mode(&self) -> bool {
752 self.metadata
753 .edit_mode
754 .as_ref()
755 .map(|edit_state| edit_state.is_active)
756 .unwrap_or(false)
757 }
758
759 pub fn get_edit_mode_info(&self) -> Option<&super::metadata::EditModeState> {
761 self.metadata.edit_mode.as_ref()
762 }
763
764 pub fn enter_edit_mode(&mut self, stack_id: Uuid, entry_id: Uuid) -> Result<()> {
766 let commit_hash = {
768 let stack = self
769 .get_stack(&stack_id)
770 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
771
772 let entry = stack.get_entry(&entry_id).ok_or_else(|| {
773 CascadeError::config(format!("Entry {entry_id} not found in stack"))
774 })?;
775
776 entry.commit_hash.clone()
777 };
778
779 if self.is_in_edit_mode() {
781 self.exit_edit_mode()?;
782 }
783
784 let edit_state = super::metadata::EditModeState::new(stack_id, entry_id, commit_hash);
786
787 self.metadata.edit_mode = Some(edit_state);
788 self.save_to_disk()?;
789
790 debug!(
791 "Entered edit mode for entry {} in stack {}",
792 entry_id, stack_id
793 );
794 Ok(())
795 }
796
797 pub fn exit_edit_mode(&mut self) -> Result<()> {
799 if !self.is_in_edit_mode() {
800 return Err(CascadeError::config("Not currently in edit mode"));
801 }
802
803 self.metadata.edit_mode = None;
805 self.save_to_disk()?;
806
807 debug!("Exited edit mode");
808 Ok(())
809 }
810
811 pub fn sync_stack(&mut self, stack_id: &Uuid) -> Result<()> {
813 let stack = self
814 .stacks
815 .get_mut(stack_id)
816 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
817
818 if let Err(integrity_error) = stack.validate_git_integrity(&self.repo) {
820 stack.update_status(StackStatus::Corrupted);
821 return Err(CascadeError::branch(format!(
822 "Stack '{}' Git integrity check failed:\n{}",
823 stack.name, integrity_error
824 )));
825 }
826
827 let mut missing_commits = Vec::new();
829 for entry in &stack.entries {
830 if !self.repo.commit_exists(&entry.commit_hash)? {
831 missing_commits.push(entry.commit_hash.clone());
832 }
833 }
834
835 if !missing_commits.is_empty() {
836 stack.update_status(StackStatus::Corrupted);
837 return Err(CascadeError::branch(format!(
838 "Stack {} has missing commits: {}",
839 stack.name,
840 missing_commits.join(", ")
841 )));
842 }
843
844 if !self.repo.branch_exists_or_fetch(&stack.base_branch)? {
846 return Err(CascadeError::branch(format!(
847 "Base branch '{}' does not exist locally or remotely. Check the branch name or switch to a different base.",
848 stack.base_branch
849 )));
850 }
851
852 let _base_hash = self.repo.get_branch_head(&stack.base_branch)?;
853
854 let mut corrupted_entry = None;
856 for entry in &stack.entries {
857 if !self.repo.commit_exists(&entry.commit_hash)? {
858 corrupted_entry = Some((entry.commit_hash.clone(), entry.branch.clone()));
859 break;
860 }
861 }
862
863 if let Some((commit_hash, branch)) = corrupted_entry {
864 stack.update_status(StackStatus::Corrupted);
865 return Err(CascadeError::branch(format!(
866 "Commit {commit_hash} from stack entry '{branch}' no longer exists"
867 )));
868 }
869
870 let needs_sync = if let Some(first_entry) = stack.entries.first() {
872 match self
874 .repo
875 .get_commits_between(&stack.base_branch, &first_entry.commit_hash)
876 {
877 Ok(commits) => !commits.is_empty(), Err(_) => true, }
880 } else {
881 false };
883
884 if needs_sync {
886 stack.update_status(StackStatus::NeedsSync);
887 debug!(
888 "Stack '{}' needs sync - new commits on base branch",
889 stack.name
890 );
891 } else {
892 stack.update_status(StackStatus::Clean);
893 debug!("Stack '{}' is clean", stack.name);
894 }
895
896 if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
898 stack_meta.set_up_to_date(true);
899 }
900
901 self.save_to_disk()?;
902
903 Ok(())
904 }
905
906 pub fn list_stacks(&self) -> Vec<(Uuid, &str, &StackStatus, usize, Option<&str>)> {
908 let active_id = self.get_active_stack_id();
909 self.stacks
910 .values()
911 .map(|stack| {
912 (
913 stack.id,
914 stack.name.as_str(),
915 &stack.status,
916 stack.entries.len(),
917 if active_id == Some(stack.id) {
918 Some("active")
919 } else {
920 None
921 },
922 )
923 })
924 .collect()
925 }
926
927 pub fn get_all_stacks_objects(&self) -> Result<Vec<Stack>> {
929 let active_id = self.get_active_stack_id();
930 let mut stacks: Vec<Stack> = self.stacks.values().cloned().collect();
931 for stack in &mut stacks {
932 stack.is_active = active_id == Some(stack.id);
933 }
934 stacks.sort_by(|a, b| a.name.cmp(&b.name));
935 Ok(stacks)
936 }
937
938 pub fn validate_all(&self) -> Result<()> {
940 for stack in self.stacks.values() {
941 stack.validate().map_err(|e| {
943 CascadeError::config(format!("Stack '{}' validation failed: {}", stack.name, e))
944 })?;
945
946 stack.validate_git_integrity(&self.repo).map_err(|e| {
948 CascadeError::config(format!(
949 "Stack '{}' Git integrity validation failed: {}",
950 stack.name, e
951 ))
952 })?;
953 }
954 Ok(())
955 }
956
957 pub fn validate_stack(&self, stack_id: &Uuid) -> Result<()> {
959 let stack = self
960 .stacks
961 .get(stack_id)
962 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
963
964 stack.validate().map_err(|e| {
966 CascadeError::config(format!("Stack '{}' validation failed: {}", stack.name, e))
967 })?;
968
969 stack.validate_git_integrity(&self.repo).map_err(|e| {
971 CascadeError::config(format!(
972 "Stack '{}' Git integrity validation failed: {}",
973 stack.name, e
974 ))
975 })?;
976
977 Ok(())
978 }
979
980 pub fn save_to_disk(&self) -> Result<()> {
982 if !self.config_dir.exists() {
984 fs::create_dir_all(&self.config_dir).map_err(|e| {
985 CascadeError::config(format!("Failed to create config directory: {e}"))
986 })?;
987 }
988
989 crate::utils::atomic_file::write_json(&self.stacks_file, &self.stacks)?;
991
992 crate::utils::atomic_file::write_json(&self.metadata_file, &self.metadata)?;
994
995 Ok(())
996 }
997
998 fn load_from_disk(&mut self) -> Result<()> {
1000 if self.stacks_file.exists() {
1002 let stacks_content = fs::read_to_string(&self.stacks_file)
1003 .map_err(|e| CascadeError::config(format!("Failed to read stacks file: {e}")))?;
1004
1005 self.stacks = serde_json::from_str(&stacks_content)
1006 .map_err(|e| CascadeError::config(format!("Failed to parse stacks file: {e}")))?;
1007 }
1008
1009 if self.metadata_file.exists() {
1011 let metadata_content = fs::read_to_string(&self.metadata_file)
1012 .map_err(|e| CascadeError::config(format!("Failed to read metadata file: {e}")))?;
1013
1014 self.metadata = serde_json::from_str(&metadata_content)
1015 .map_err(|e| CascadeError::config(format!("Failed to parse metadata file: {e}")))?;
1016 }
1017
1018 Ok(())
1019 }
1020
1021 pub fn handle_branch_modifications(
1024 &mut self,
1025 stack_id: &Uuid,
1026 auto_mode: Option<String>,
1027 ) -> Result<()> {
1028 let stack = self
1029 .stacks
1030 .get_mut(stack_id)
1031 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
1032
1033 debug!("Checking Git integrity for stack '{}'", stack.name);
1034
1035 let mut modifications = Vec::new();
1037 for entry in &stack.entries {
1038 if !self.repo.branch_exists(&entry.branch) {
1039 modifications.push(BranchModification::Missing {
1040 branch: entry.branch.clone(),
1041 entry_id: entry.id,
1042 expected_commit: entry.commit_hash.clone(),
1043 });
1044 } else if let Ok(branch_head) = self.repo.get_branch_head(&entry.branch) {
1045 if branch_head != entry.commit_hash {
1046 let extra_commits = self
1048 .repo
1049 .get_commits_between(&entry.commit_hash, &branch_head)?;
1050 let mut extra_messages = Vec::new();
1051 for commit in &extra_commits {
1052 if let Some(message) = commit.message() {
1053 let first_line =
1054 message.lines().next().unwrap_or("(no message)").to_string();
1055 extra_messages.push(format!(
1056 "{}: {}",
1057 &commit.id().to_string()[..8],
1058 first_line
1059 ));
1060 }
1061 }
1062
1063 modifications.push(BranchModification::ExtraCommits {
1064 branch: entry.branch.clone(),
1065 entry_id: entry.id,
1066 expected_commit: entry.commit_hash.clone(),
1067 actual_commit: branch_head,
1068 extra_commit_count: extra_commits.len(),
1069 extra_commit_messages: extra_messages,
1070 });
1071 }
1072 }
1073 }
1074
1075 if modifications.is_empty() {
1076 return Ok(());
1078 }
1079
1080 println!();
1082 Output::section(format!("Branch modifications detected in '{}'", stack.name));
1083 for (i, modification) in modifications.iter().enumerate() {
1084 match modification {
1085 BranchModification::Missing { branch, .. } => {
1086 Output::numbered_item(i + 1, format!("Branch '{branch}' is missing"));
1087 }
1088 BranchModification::ExtraCommits {
1089 branch,
1090 expected_commit,
1091 actual_commit,
1092 extra_commit_count,
1093 extra_commit_messages,
1094 ..
1095 } => {
1096 println!(
1097 " {}. Branch '{}' has {} extra commit(s)",
1098 i + 1,
1099 branch,
1100 extra_commit_count
1101 );
1102 println!(
1103 " Expected: {} | Actual: {}",
1104 &expected_commit[..8],
1105 &actual_commit[..8]
1106 );
1107
1108 for (j, message) in extra_commit_messages.iter().enumerate() {
1110 match j.cmp(&3) {
1111 std::cmp::Ordering::Less => {
1112 Output::sub_item(format!(" + {message}"));
1113 }
1114 std::cmp::Ordering::Equal => {
1115 Output::sub_item(format!(
1116 " + ... and {} more",
1117 extra_commit_count - 3
1118 ));
1119 break;
1120 }
1121 std::cmp::Ordering::Greater => {
1122 break;
1123 }
1124 }
1125 }
1126 }
1127 }
1128 }
1129 Output::spacing();
1130
1131 if let Some(mode) = auto_mode {
1133 return self.apply_auto_fix(stack_id, &modifications, &mode);
1134 }
1135
1136 let mut handled_count = 0;
1138 let mut skipped_count = 0;
1139 for modification in modifications.iter() {
1140 let was_skipped = self.handle_single_modification(stack_id, modification)?;
1141 if was_skipped {
1142 skipped_count += 1;
1143 } else {
1144 handled_count += 1;
1145 }
1146 }
1147
1148 self.save_to_disk()?;
1149
1150 if skipped_count == 0 {
1152 Output::success("All branch modifications resolved");
1153 } else if handled_count > 0 {
1154 Output::warning(format!(
1155 "Resolved {} modification(s), {} skipped",
1156 handled_count, skipped_count
1157 ));
1158 } else {
1159 Output::warning("All modifications skipped - integrity issues remain");
1160 }
1161
1162 Ok(())
1163 }
1164
1165 fn handle_single_modification(
1168 &mut self,
1169 stack_id: &Uuid,
1170 modification: &BranchModification,
1171 ) -> Result<bool> {
1172 match modification {
1173 BranchModification::Missing {
1174 branch,
1175 expected_commit,
1176 ..
1177 } => {
1178 Output::info(format!("Missing branch '{branch}'"));
1179 Output::sub_item(format!(
1180 "Will create the branch at commit {}",
1181 &expected_commit[..8]
1182 ));
1183
1184 self.repo.create_branch(branch, Some(expected_commit))?;
1185 Output::success(format!("Created branch '{branch}'"));
1186 Ok(false) }
1188
1189 BranchModification::ExtraCommits {
1190 branch,
1191 entry_id,
1192 expected_commit,
1193 extra_commit_count,
1194 ..
1195 } => {
1196 println!();
1197 Output::info(format!(
1198 "Branch '{}' has {} extra commit(s)",
1199 branch, extra_commit_count
1200 ));
1201 let options = vec![
1202 "Incorporate - Update stack entry to include extra commits",
1203 "Split - Create new stack entry for extra commits",
1204 "Reset - Remove extra commits (DESTRUCTIVE)",
1205 "Skip - Leave as-is for now",
1206 ];
1207
1208 let choice = Select::with_theme(&ColorfulTheme::default())
1209 .with_prompt("Choose how to handle extra commits")
1210 .default(0)
1211 .items(&options)
1212 .interact()
1213 .map_err(|e| CascadeError::config(format!("Failed to get user choice: {e}")))?;
1214
1215 match choice {
1216 0 => {
1217 self.incorporate_extra_commits(stack_id, *entry_id, branch)?;
1218 Ok(false) }
1220 1 => {
1221 self.split_extra_commits(stack_id, *entry_id, branch)?;
1222 Ok(false) }
1224 2 => {
1225 self.reset_branch_destructive(branch, expected_commit)?;
1226 Ok(false) }
1228 3 => {
1229 Output::warning(format!("Skipped '{branch}' - integrity issue remains"));
1230 Ok(true) }
1232 _ => {
1233 Output::warning(format!("Invalid choice - skipped '{branch}'"));
1234 Ok(true) }
1236 }
1237 }
1238 }
1239 }
1240
1241 fn apply_auto_fix(
1243 &mut self,
1244 stack_id: &Uuid,
1245 modifications: &[BranchModification],
1246 mode: &str,
1247 ) -> Result<()> {
1248 Output::info(format!("š¤ Applying automatic fix mode: {mode}"));
1249
1250 for modification in modifications {
1251 match (modification, mode) {
1252 (
1253 BranchModification::Missing {
1254 branch,
1255 expected_commit,
1256 ..
1257 },
1258 _,
1259 ) => {
1260 self.repo.create_branch(branch, Some(expected_commit))?;
1261 Output::success(format!("Created missing branch '{branch}'"));
1262 }
1263
1264 (
1265 BranchModification::ExtraCommits {
1266 branch, entry_id, ..
1267 },
1268 "incorporate",
1269 ) => {
1270 self.incorporate_extra_commits(stack_id, *entry_id, branch)?;
1271 }
1272
1273 (
1274 BranchModification::ExtraCommits {
1275 branch, entry_id, ..
1276 },
1277 "split",
1278 ) => {
1279 self.split_extra_commits(stack_id, *entry_id, branch)?;
1280 }
1281
1282 (
1283 BranchModification::ExtraCommits {
1284 branch,
1285 expected_commit,
1286 ..
1287 },
1288 "reset",
1289 ) => {
1290 self.reset_branch_destructive(branch, expected_commit)?;
1291 }
1292
1293 _ => {
1294 return Err(CascadeError::config(format!(
1295 "Unknown auto-fix mode '{mode}'. Use: incorporate, split, reset"
1296 )));
1297 }
1298 }
1299 }
1300
1301 self.save_to_disk()?;
1302 Output::success(format!("Auto-fix completed for mode: {mode}"));
1303 Ok(())
1304 }
1305
1306 fn incorporate_extra_commits(
1308 &mut self,
1309 stack_id: &Uuid,
1310 entry_id: Uuid,
1311 branch: &str,
1312 ) -> Result<()> {
1313 let stack = self
1314 .stacks
1315 .get_mut(stack_id)
1316 .ok_or_else(|| CascadeError::config(format!("Stack not found: {}", stack_id)))?;
1317
1318 let entry_info = stack
1320 .entries
1321 .iter()
1322 .find(|e| e.id == entry_id)
1323 .map(|e| (e.commit_hash.clone(), e.id));
1324
1325 if let Some((old_commit_hash, entry_id)) = entry_info {
1326 let new_head = self.repo.get_branch_head(branch)?;
1327 let old_commit = old_commit_hash[..8].to_string();
1328
1329 let extra_commits = self.repo.get_commits_between(&old_commit_hash, &new_head)?;
1331
1332 stack
1336 .update_entry_commit_hash(&entry_id, new_head.clone())
1337 .map_err(CascadeError::config)?;
1338
1339 Output::success(format!(
1340 "Incorporated {} commit(s) into entry '{}'",
1341 extra_commits.len(),
1342 &new_head[..8]
1343 ));
1344 Output::sub_item(format!("Updated: {} -> {}", old_commit, &new_head[..8]));
1345 }
1346
1347 Ok(())
1348 }
1349
1350 fn split_extra_commits(&mut self, stack_id: &Uuid, entry_id: Uuid, branch: &str) -> Result<()> {
1352 let stack = self
1353 .stacks
1354 .get_mut(stack_id)
1355 .ok_or_else(|| CascadeError::config(format!("Stack not found: {}", stack_id)))?;
1356 let new_head = self.repo.get_branch_head(branch)?;
1357
1358 let entry_position = stack
1360 .entries
1361 .iter()
1362 .position(|e| e.id == entry_id)
1363 .ok_or_else(|| CascadeError::config("Entry not found in stack"))?;
1364
1365 let base_name = branch.trim_end_matches(|c: char| c.is_ascii_digit() || c == '-');
1367 let new_branch = format!("{base_name}-continued");
1368
1369 self.repo.create_branch(&new_branch, Some(&new_head))?;
1371
1372 let original_entry = &stack.entries[entry_position];
1374 let original_commit_hash = original_entry.commit_hash.clone(); let extra_commits = self
1376 .repo
1377 .get_commits_between(&original_commit_hash, &new_head)?;
1378
1379 let mut extra_messages = Vec::new();
1381 for commit in &extra_commits {
1382 if let Some(message) = commit.message() {
1383 let first_line = message.lines().next().unwrap_or("").to_string();
1384 extra_messages.push(first_line);
1385 }
1386 }
1387
1388 let new_message = if extra_messages.len() == 1 {
1389 extra_messages[0].clone()
1390 } else {
1391 format!("Combined changes:\n⢠{}", extra_messages.join("\n⢠"))
1392 };
1393
1394 let now = Utc::now();
1396 let new_entry = crate::stack::StackEntry {
1397 id: uuid::Uuid::new_v4(),
1398 branch: new_branch.clone(),
1399 commit_hash: new_head,
1400 message: new_message,
1401 parent_id: Some(entry_id), children: Vec::new(),
1403 created_at: now,
1404 updated_at: now,
1405 is_submitted: false,
1406 pull_request_id: None,
1407 is_synced: false,
1408 is_merged: false,
1409 };
1410
1411 stack.entries.insert(entry_position + 1, new_entry);
1413
1414 self.repo
1416 .reset_branch_to_commit(branch, &original_commit_hash)?;
1417
1418 println!(
1419 " ā
Split {} commit(s) into new entry '{}'",
1420 extra_commits.len(),
1421 new_branch
1422 );
1423 println!(" Original branch '{branch}' reset to expected commit");
1424
1425 Ok(())
1426 }
1427
1428 fn reset_branch_destructive(&self, branch: &str, expected_commit: &str) -> Result<()> {
1430 self.repo.reset_branch_to_commit(branch, expected_commit)?;
1431 Output::warning(format!(
1432 "Reset branch '{}' to {} (extra commits lost)",
1433 branch,
1434 &expected_commit[..8]
1435 ));
1436 Ok(())
1437 }
1438}
1439
1440#[cfg(test)]
1441mod tests {
1442 use super::*;
1443 use std::process::Command;
1444 use tempfile::TempDir;
1445
1446 fn create_test_repo() -> (TempDir, PathBuf) {
1447 let temp_dir = TempDir::new().unwrap();
1448 let repo_path = temp_dir.path().to_path_buf();
1449
1450 Command::new("git")
1452 .args(["init"])
1453 .current_dir(&repo_path)
1454 .output()
1455 .unwrap();
1456
1457 Command::new("git")
1459 .args(["config", "user.name", "Test User"])
1460 .current_dir(&repo_path)
1461 .output()
1462 .unwrap();
1463
1464 Command::new("git")
1465 .args(["config", "user.email", "test@example.com"])
1466 .current_dir(&repo_path)
1467 .output()
1468 .unwrap();
1469
1470 std::fs::write(repo_path.join("README.md"), "# Test Repo").unwrap();
1472 Command::new("git")
1473 .args(["add", "."])
1474 .current_dir(&repo_path)
1475 .output()
1476 .unwrap();
1477
1478 Command::new("git")
1479 .args(["commit", "-m", "Initial commit"])
1480 .current_dir(&repo_path)
1481 .output()
1482 .unwrap();
1483
1484 crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
1486 .unwrap();
1487
1488 (temp_dir, repo_path)
1489 }
1490
1491 #[test]
1492 fn test_create_stack_manager() {
1493 let (_temp_dir, repo_path) = create_test_repo();
1494 let manager = StackManager::new(&repo_path).unwrap();
1495
1496 assert_eq!(manager.stacks.len(), 0);
1497 assert!(manager.get_active_stack().is_none());
1498 }
1499
1500 #[test]
1501 fn test_create_and_manage_stack() {
1502 let (_temp_dir, repo_path) = create_test_repo();
1503
1504 Command::new("git")
1506 .args(["checkout", "-b", "feature/test-work"])
1507 .current_dir(&repo_path)
1508 .output()
1509 .unwrap();
1510
1511 let mut manager = StackManager::new(&repo_path).unwrap();
1512
1513 let stack_id = manager
1515 .create_stack(
1516 "test-stack".to_string(),
1517 None, Some("Test stack description".to_string()),
1519 )
1520 .unwrap();
1521
1522 assert_eq!(manager.stacks.len(), 1);
1524 let stack = manager.get_stack(&stack_id).unwrap();
1525 assert_eq!(stack.name, "test-stack");
1526 assert!(!stack.base_branch.is_empty());
1528 assert_eq!(stack.working_branch.as_deref(), Some("feature/test-work"));
1530
1531 let active = manager.get_active_stack().unwrap();
1533 assert_eq!(active.id, stack_id);
1534
1535 let found = manager.get_stack_by_name("test-stack").unwrap();
1537 assert_eq!(found.id, stack_id);
1538 }
1539
1540 #[test]
1541 fn test_stack_persistence() {
1542 let (_temp_dir, repo_path) = create_test_repo();
1543
1544 Command::new("git")
1545 .args(["checkout", "-b", "feature/persist-work"])
1546 .current_dir(&repo_path)
1547 .output()
1548 .unwrap();
1549
1550 let stack_id = {
1551 let mut manager = StackManager::new(&repo_path).unwrap();
1552 manager
1553 .create_stack("persistent-stack".to_string(), None, None)
1554 .unwrap()
1555 };
1556
1557 let manager = StackManager::new(&repo_path).unwrap();
1559 assert_eq!(manager.stacks.len(), 1);
1560 let stack = manager.get_stack(&stack_id).unwrap();
1561 assert_eq!(stack.name, "persistent-stack");
1562 }
1563
1564 #[test]
1565 fn test_multiple_stacks() {
1566 let (_temp_dir, repo_path) = create_test_repo();
1567 let mut manager = StackManager::new(&repo_path).unwrap();
1568
1569 Command::new("git")
1571 .args(["checkout", "-b", "feature/stack-1"])
1572 .current_dir(&repo_path)
1573 .output()
1574 .unwrap();
1575
1576 let stack1_id = manager
1577 .create_stack("stack-1".to_string(), None, None)
1578 .unwrap();
1579
1580 Command::new("git")
1582 .args(["checkout", "-b", "feature/stack-2"])
1583 .current_dir(&repo_path)
1584 .output()
1585 .unwrap();
1586
1587 let stack2_id = manager
1588 .create_stack("stack-2".to_string(), None, None)
1589 .unwrap();
1590
1591 assert_eq!(manager.stacks.len(), 2);
1592
1593 assert_eq!(manager.get_active_stack_id(), Some(stack2_id));
1595
1596 Command::new("git")
1598 .args(["checkout", "feature/stack-1"])
1599 .current_dir(&repo_path)
1600 .output()
1601 .unwrap();
1602
1603 let manager = StackManager::new(&repo_path).unwrap();
1605 assert_eq!(manager.get_active_stack_id(), Some(stack1_id));
1606 }
1607
1608 #[test]
1609 fn test_delete_stack() {
1610 let (_temp_dir, repo_path) = create_test_repo();
1611 let mut manager = StackManager::new(&repo_path).unwrap();
1612
1613 let stack_id = manager
1614 .create_stack("to-delete".to_string(), None, None)
1615 .unwrap();
1616 assert_eq!(manager.stacks.len(), 1);
1617
1618 let deleted = manager.delete_stack(&stack_id).unwrap();
1619 assert_eq!(deleted.name, "to-delete");
1620 assert_eq!(manager.stacks.len(), 0);
1621 assert!(manager.get_active_stack().is_none());
1622 }
1623
1624 #[test]
1625 fn test_validation() {
1626 let (_temp_dir, repo_path) = create_test_repo();
1627 let mut manager = StackManager::new(&repo_path).unwrap();
1628
1629 manager
1630 .create_stack("valid-stack".to_string(), None, None)
1631 .unwrap();
1632
1633 assert!(manager.validate_all().is_ok());
1635 }
1636
1637 #[test]
1638 fn test_duplicate_commit_message_detection() {
1639 let (_temp_dir, repo_path) = create_test_repo();
1640
1641 Command::new("git")
1643 .args(["checkout", "-b", "feature/test-dup"])
1644 .current_dir(&repo_path)
1645 .output()
1646 .unwrap();
1647
1648 let mut manager = StackManager::new(&repo_path).unwrap();
1649
1650 manager
1652 .create_stack("test-stack".to_string(), None, None)
1653 .unwrap();
1654
1655 std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1657 Command::new("git")
1658 .args(["add", "file1.txt"])
1659 .current_dir(&repo_path)
1660 .output()
1661 .unwrap();
1662
1663 Command::new("git")
1664 .args(["commit", "-m", "Add authentication feature"])
1665 .current_dir(&repo_path)
1666 .output()
1667 .unwrap();
1668
1669 let commit1_hash = Command::new("git")
1670 .args(["rev-parse", "HEAD"])
1671 .current_dir(&repo_path)
1672 .output()
1673 .unwrap();
1674 let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1675 .trim()
1676 .to_string();
1677
1678 let entry1_id = manager
1680 .push_to_stack(
1681 "feature/auth".to_string(),
1682 commit1_hash,
1683 "Add authentication feature".to_string(),
1684 "main".to_string(),
1685 )
1686 .unwrap();
1687
1688 assert!(manager
1690 .get_active_stack()
1691 .unwrap()
1692 .get_entry(&entry1_id)
1693 .is_some());
1694
1695 std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1697 Command::new("git")
1698 .args(["add", "file2.txt"])
1699 .current_dir(&repo_path)
1700 .output()
1701 .unwrap();
1702
1703 Command::new("git")
1704 .args(["commit", "-m", "Different commit message"])
1705 .current_dir(&repo_path)
1706 .output()
1707 .unwrap();
1708
1709 let commit2_hash = Command::new("git")
1710 .args(["rev-parse", "HEAD"])
1711 .current_dir(&repo_path)
1712 .output()
1713 .unwrap();
1714 let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1715 .trim()
1716 .to_string();
1717
1718 let result = manager.push_to_stack(
1720 "feature/auth2".to_string(),
1721 commit2_hash.clone(),
1722 "Add authentication feature".to_string(), "main".to_string(),
1724 );
1725
1726 assert!(result.is_err());
1728 let error = result.unwrap_err();
1729 assert!(matches!(error, CascadeError::Validation(_)));
1730
1731 let error_msg = error.to_string();
1733 assert!(error_msg.contains("Duplicate commit message"));
1734 assert!(error_msg.contains("Add authentication feature"));
1735 assert!(error_msg.contains("š” Consider using a more specific message"));
1736
1737 let entry2_id = manager
1739 .push_to_stack(
1740 "feature/auth2".to_string(),
1741 commit2_hash,
1742 "Add authentication validation".to_string(), "main".to_string(),
1744 )
1745 .unwrap();
1746
1747 let stack = manager.get_active_stack().unwrap();
1749 assert_eq!(stack.entries.len(), 2);
1750 assert!(stack.get_entry(&entry1_id).is_some());
1751 assert!(stack.get_entry(&entry2_id).is_some());
1752 }
1753
1754 #[test]
1755 fn test_duplicate_message_with_different_case() {
1756 let (_temp_dir, repo_path) = create_test_repo();
1757
1758 Command::new("git")
1759 .args(["checkout", "-b", "feature/test-case"])
1760 .current_dir(&repo_path)
1761 .output()
1762 .unwrap();
1763
1764 let mut manager = StackManager::new(&repo_path).unwrap();
1765
1766 manager
1767 .create_stack("test-stack".to_string(), None, None)
1768 .unwrap();
1769
1770 std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1772 Command::new("git")
1773 .args(["add", "file1.txt"])
1774 .current_dir(&repo_path)
1775 .output()
1776 .unwrap();
1777
1778 Command::new("git")
1779 .args(["commit", "-m", "fix bug"])
1780 .current_dir(&repo_path)
1781 .output()
1782 .unwrap();
1783
1784 let commit1_hash = Command::new("git")
1785 .args(["rev-parse", "HEAD"])
1786 .current_dir(&repo_path)
1787 .output()
1788 .unwrap();
1789 let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1790 .trim()
1791 .to_string();
1792
1793 manager
1794 .push_to_stack(
1795 "feature/fix1".to_string(),
1796 commit1_hash,
1797 "fix bug".to_string(),
1798 "main".to_string(),
1799 )
1800 .unwrap();
1801
1802 std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1804 Command::new("git")
1805 .args(["add", "file2.txt"])
1806 .current_dir(&repo_path)
1807 .output()
1808 .unwrap();
1809
1810 Command::new("git")
1811 .args(["commit", "-m", "Fix Bug"])
1812 .current_dir(&repo_path)
1813 .output()
1814 .unwrap();
1815
1816 let commit2_hash = Command::new("git")
1817 .args(["rev-parse", "HEAD"])
1818 .current_dir(&repo_path)
1819 .output()
1820 .unwrap();
1821 let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1822 .trim()
1823 .to_string();
1824
1825 let result = manager.push_to_stack(
1827 "feature/fix2".to_string(),
1828 commit2_hash,
1829 "Fix Bug".to_string(), "main".to_string(),
1831 );
1832
1833 assert!(result.is_ok());
1835 }
1836
1837 #[test]
1838 fn test_duplicate_message_across_different_stacks() {
1839 let (_temp_dir, repo_path) = create_test_repo();
1840
1841 Command::new("git")
1843 .args(["checkout", "-b", "feature/stack1-work"])
1844 .current_dir(&repo_path)
1845 .output()
1846 .unwrap();
1847
1848 let mut manager = StackManager::new(&repo_path).unwrap();
1849
1850 let stack1_id = manager
1852 .create_stack("stack1".to_string(), None, None)
1853 .unwrap();
1854
1855 std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1856 Command::new("git")
1857 .args(["add", "file1.txt"])
1858 .current_dir(&repo_path)
1859 .output()
1860 .unwrap();
1861
1862 Command::new("git")
1863 .args(["commit", "-m", "shared message"])
1864 .current_dir(&repo_path)
1865 .output()
1866 .unwrap();
1867
1868 let commit1_hash = Command::new("git")
1869 .args(["rev-parse", "HEAD"])
1870 .current_dir(&repo_path)
1871 .output()
1872 .unwrap();
1873 let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1874 .trim()
1875 .to_string();
1876
1877 manager
1878 .push_to_stack(
1879 "feature/shared1".to_string(),
1880 commit1_hash,
1881 "shared message".to_string(),
1882 "main".to_string(),
1883 )
1884 .unwrap();
1885
1886 Command::new("git")
1888 .args(["checkout", "-b", "feature/stack2-work"])
1889 .current_dir(&repo_path)
1890 .output()
1891 .unwrap();
1892
1893 let stack2_id = manager
1894 .create_stack("stack2".to_string(), None, None)
1895 .unwrap();
1896
1897 let mut manager = StackManager::new(&repo_path).unwrap();
1899
1900 std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1902 Command::new("git")
1903 .args(["add", "file2.txt"])
1904 .current_dir(&repo_path)
1905 .output()
1906 .unwrap();
1907
1908 Command::new("git")
1909 .args(["commit", "-m", "shared message"])
1910 .current_dir(&repo_path)
1911 .output()
1912 .unwrap();
1913
1914 let commit2_hash = Command::new("git")
1915 .args(["rev-parse", "HEAD"])
1916 .current_dir(&repo_path)
1917 .output()
1918 .unwrap();
1919 let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1920 .trim()
1921 .to_string();
1922
1923 let result = manager.push_to_stack(
1925 "feature/shared2".to_string(),
1926 commit2_hash,
1927 "shared message".to_string(), "main".to_string(),
1929 );
1930
1931 assert!(result.is_ok());
1933
1934 let stack1 = manager.get_stack(&stack1_id).unwrap();
1936 let stack2 = manager.get_stack(&stack2_id).unwrap();
1937
1938 assert_eq!(stack1.entries.len(), 1);
1939 assert_eq!(stack2.entries.len(), 1);
1940 assert_eq!(stack1.entries[0].message, "shared message");
1941 assert_eq!(stack2.entries[0].message, "shared message");
1942 }
1943
1944 #[test]
1945 fn test_duplicate_after_pop() {
1946 let (_temp_dir, repo_path) = create_test_repo();
1947
1948 Command::new("git")
1949 .args(["checkout", "-b", "feature/test-pop"])
1950 .current_dir(&repo_path)
1951 .output()
1952 .unwrap();
1953
1954 let mut manager = StackManager::new(&repo_path).unwrap();
1955
1956 manager
1957 .create_stack("test-stack".to_string(), None, None)
1958 .unwrap();
1959
1960 std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1962 Command::new("git")
1963 .args(["add", "file1.txt"])
1964 .current_dir(&repo_path)
1965 .output()
1966 .unwrap();
1967
1968 Command::new("git")
1969 .args(["commit", "-m", "temporary message"])
1970 .current_dir(&repo_path)
1971 .output()
1972 .unwrap();
1973
1974 let commit1_hash = Command::new("git")
1975 .args(["rev-parse", "HEAD"])
1976 .current_dir(&repo_path)
1977 .output()
1978 .unwrap();
1979 let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1980 .trim()
1981 .to_string();
1982
1983 manager
1984 .push_to_stack(
1985 "feature/temp".to_string(),
1986 commit1_hash,
1987 "temporary message".to_string(),
1988 "main".to_string(),
1989 )
1990 .unwrap();
1991
1992 let popped = manager.pop_from_stack().unwrap();
1994 assert_eq!(popped.message, "temporary message");
1995
1996 std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1998 Command::new("git")
1999 .args(["add", "file2.txt"])
2000 .current_dir(&repo_path)
2001 .output()
2002 .unwrap();
2003
2004 Command::new("git")
2005 .args(["commit", "-m", "temporary message"])
2006 .current_dir(&repo_path)
2007 .output()
2008 .unwrap();
2009
2010 let commit2_hash = Command::new("git")
2011 .args(["rev-parse", "HEAD"])
2012 .current_dir(&repo_path)
2013 .output()
2014 .unwrap();
2015 let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
2016 .trim()
2017 .to_string();
2018
2019 let result = manager.push_to_stack(
2021 "feature/temp2".to_string(),
2022 commit2_hash,
2023 "temporary message".to_string(),
2024 "main".to_string(),
2025 );
2026
2027 assert!(result.is_ok());
2028 }
2029}