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 dialoguer::{theme::ColorfulTheme, Input, Select};
8use std::collections::HashMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11use tracing::debug;
12use uuid::Uuid;
13
14#[derive(Debug)]
16pub enum BranchModification {
17 Missing {
19 branch: String,
20 entry_id: Uuid,
21 expected_commit: String,
22 },
23 ExtraCommits {
25 branch: String,
26 entry_id: Uuid,
27 expected_commit: String,
28 actual_commit: String,
29 extra_commit_count: usize,
30 extra_commit_messages: Vec<String>,
31 },
32}
33
34pub struct StackManager {
36 repo: GitRepository,
38 repo_path: PathBuf,
40 config_dir: PathBuf,
42 stacks_file: PathBuf,
44 metadata_file: PathBuf,
46 stacks: HashMap<Uuid, Stack>,
48 metadata: RepositoryMetadata,
50}
51
52impl StackManager {
53 pub fn new(repo_path: &Path) -> Result<Self> {
55 let repo = GitRepository::open(repo_path)?;
56 let config_dir = get_repo_config_dir(repo_path)?;
57 let stacks_file = config_dir.join("stacks.json");
58 let metadata_file = config_dir.join("metadata.json");
59
60 let config_file = config_dir.join("config.json");
62 let settings = Settings::load_from_file(&config_file).unwrap_or_default();
63 let configured_default = &settings.git.default_branch;
64
65 let default_base = if repo.branch_exists(configured_default) {
69 configured_default.clone()
71 } else {
72 match repo.detect_main_branch() {
74 Ok(detected) => {
75 detected
77 }
78 Err(_) => {
79 configured_default.clone()
82 }
83 }
84 };
85
86 let mut manager = Self {
87 repo,
88 repo_path: repo_path.to_path_buf(),
89 config_dir,
90 stacks_file,
91 metadata_file,
92 stacks: HashMap::new(),
93 metadata: RepositoryMetadata::new(default_base),
94 };
95
96 manager.load_from_disk()?;
98
99 Ok(manager)
100 }
101
102 pub fn create_stack(
104 &mut self,
105 name: String,
106 base_branch: Option<String>,
107 description: Option<String>,
108 ) -> Result<Uuid> {
109 if self.metadata.find_stack_by_name(&name).is_some() {
111 return Err(CascadeError::config(format!(
112 "Stack '{name}' already exists"
113 )));
114 }
115
116 let base_branch = base_branch.unwrap_or_else(|| {
118 if let Ok(Some(detected_parent)) = self.repo.detect_parent_branch() {
120 detected_parent
121 } else {
122 self.metadata.default_base_branch.clone()
124 }
125 });
126
127 if !self.repo.branch_exists_or_fetch(&base_branch)? {
129 return Err(CascadeError::branch(format!(
130 "Base branch '{base_branch}' does not exist locally or remotely"
131 )));
132 }
133
134 let current_branch = self.repo.get_current_branch().ok();
136
137 let mut stack = Stack::new(name.clone(), base_branch.clone(), description.clone());
139
140 if let Some(ref branch) = current_branch {
142 if branch != &base_branch {
143 stack.working_branch = Some(branch.clone());
144 }
145 }
146
147 let stack_id = stack.id;
148
149 let stack_metadata = StackMetadata::new(stack_id, name, base_branch, description);
151
152 self.stacks.insert(stack_id, stack);
154 self.metadata.add_stack(stack_metadata);
155
156 self.set_active_stack(Some(stack_id))?;
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 pub fn get_active_stack(&self) -> Option<&Stack> {
203 self.metadata
204 .active_stack_id
205 .and_then(|id| self.stacks.get(&id))
206 }
207
208 pub fn get_active_stack_mut(&mut self) -> Option<&mut Stack> {
210 if let Some(id) = self.metadata.active_stack_id {
211 self.stacks.get_mut(&id)
212 } else {
213 None
214 }
215 }
216
217 pub fn set_active_stack(&mut self, stack_id: Option<Uuid>) -> Result<()> {
219 if let Some(id) = stack_id {
221 if !self.stacks.contains_key(&id) {
222 return Err(CascadeError::config(format!(
223 "Stack with ID {id} not found"
224 )));
225 }
226 }
227
228 if self.is_in_edit_mode() {
231 if let Some(edit_info) = self.get_edit_mode_info() {
232 if edit_info.target_stack_id != stack_id {
234 tracing::debug!(
235 "Clearing edit mode when switching from stack {:?} to {:?}",
236 edit_info.target_stack_id,
237 stack_id
238 );
239 self.exit_edit_mode()?;
240 }
241 }
242 }
243
244 for stack in self.stacks.values_mut() {
246 stack.set_active(Some(stack.id) == stack_id);
247 }
248
249 if let Some(id) = stack_id {
251 let current_branch = self.repo.get_current_branch().ok();
252 if let Some(stack_meta) = self.metadata.get_stack_mut(&id) {
253 stack_meta.set_current_branch(current_branch);
254 }
255 }
256
257 self.metadata.set_active_stack(stack_id);
258 self.save_to_disk()?;
259
260 Ok(())
261 }
262
263 pub fn set_active_stack_by_name(&mut self, name: &str) -> Result<()> {
265 if let Some(metadata) = self.metadata.find_stack_by_name(name) {
266 self.set_active_stack(Some(metadata.stack_id))
267 } else {
268 Err(CascadeError::config(format!("Stack '{name}' not found")))
269 }
270 }
271
272 pub fn delete_stack(&mut self, stack_id: &Uuid) -> Result<Stack> {
274 let stack = self
275 .stacks
276 .remove(stack_id)
277 .ok_or_else(|| CascadeError::config(format!("Stack with ID {stack_id} not found")))?;
278
279 self.metadata.remove_stack(stack_id);
281
282 let stack_commits: Vec<String> = self
284 .metadata
285 .commits
286 .values()
287 .filter(|commit| &commit.stack_id == stack_id)
288 .map(|commit| commit.hash.clone())
289 .collect();
290
291 for commit_hash in stack_commits {
292 self.metadata.remove_commit(&commit_hash);
293 }
294
295 if self.metadata.active_stack_id == Some(*stack_id) {
297 let new_active = self.metadata.stacks.keys().next().copied();
298 self.set_active_stack(new_active)?;
299 }
300
301 self.save_to_disk()?;
302
303 Ok(stack)
304 }
305
306 pub fn push_to_stack(
308 &mut self,
309 branch: String,
310 commit_hash: String,
311 message: String,
312 source_branch: String,
313 ) -> Result<Uuid> {
314 let stack_id = self
315 .metadata
316 .active_stack_id
317 .ok_or_else(|| CascadeError::config("No active stack"))?;
318
319 let mut reconciled = false;
322 {
323 let stack = self
324 .stacks
325 .get_mut(&stack_id)
326 .ok_or_else(|| CascadeError::config("Active stack not found"))?;
327
328 if !stack.entries.is_empty() {
329 let mut updates = Vec::new();
331 for entry in &stack.entries {
332 if let Ok(current_commit) = self.repo.get_branch_head(&entry.branch) {
333 if entry.commit_hash != current_commit {
334 debug!(
335 "Reconciling stale metadata for '{}': updating hash from {} to {} (current branch HEAD)",
336 entry.branch,
337 &entry.commit_hash[..8],
338 ¤t_commit[..8]
339 );
340 updates.push((entry.id, current_commit));
341 }
342 }
343 }
344
345 for (entry_id, new_hash) in updates {
347 stack
348 .update_entry_commit_hash(&entry_id, new_hash)
349 .map_err(CascadeError::config)?;
350 reconciled = true;
351 }
352 }
353 } if reconciled {
357 debug!("Saving reconciled metadata before validation");
358 self.save_to_disk()?;
359 }
360
361 let stack = self
363 .stacks
364 .get_mut(&stack_id)
365 .ok_or_else(|| CascadeError::config("Active stack not found"))?;
366
367 if !stack.entries.is_empty() {
368 if let Err(integrity_error) = stack.validate_git_integrity(&self.repo) {
369 return Err(CascadeError::validation(format!(
370 "Git integrity validation failed:\n{}\n\n\
371 Fix the stack integrity issues first using 'ca stack validate {}' for details.",
372 integrity_error, stack.name
373 )));
374 }
375 }
376
377 if !self.repo.commit_exists(&commit_hash)? {
379 return Err(CascadeError::branch(format!(
380 "Commit {commit_hash} does not exist"
381 )));
382 }
383
384 let message_trimmed = message.trim();
387 if let Some(duplicate_entry) = stack
388 .entries
389 .iter()
390 .find(|entry| entry.message.trim() == message_trimmed)
391 {
392 return Err(CascadeError::validation(format!(
393 "Duplicate commit message in stack: \"{}\"\n\n\
394 This message already exists in entry {} (commit: {})\n\n\
395 š” Consider using a more specific message:\n\
396 ⢠Add context: \"{} - add validation\"\n\
397 ⢠Be more specific: \"Fix user authentication timeout bug\"\n\
398 ⢠Or amend the previous commit: git commit --amend",
399 message_trimmed,
400 duplicate_entry.id,
401 &duplicate_entry.commit_hash[..8],
402 message_trimmed
403 )));
404 }
405
406 if stack.entries.is_empty() {
411 let current_branch = self.repo.get_current_branch()?;
412
413 if stack.working_branch.is_none() && current_branch != stack.base_branch {
415 stack.working_branch = Some(current_branch.clone());
416 tracing::info!(
417 "Set working branch for stack '{}' to '{}'",
418 stack.name,
419 current_branch
420 );
421 }
422
423 if current_branch != stack.base_branch && current_branch != "HEAD" {
424 let base_exists = self.repo.branch_exists(&stack.base_branch);
426 let current_is_feature = current_branch.starts_with("feature/")
427 || current_branch.starts_with("fix/")
428 || current_branch.starts_with("chore/")
429 || current_branch.contains("feature")
430 || current_branch.contains("fix");
431
432 if base_exists && current_is_feature {
433 tracing::info!(
434 "šÆ First commit detected: updating stack '{}' base branch from '{}' to '{}'",
435 stack.name, stack.base_branch, current_branch
436 );
437
438 Output::info("šÆ Smart Base Branch Update:");
439 Output::sub_item(format!(
440 "Stack '{}' was created with base '{}'",
441 stack.name, stack.base_branch
442 ));
443 Output::sub_item(format!(
444 "You're now working on feature branch '{current_branch}'"
445 ));
446 Output::sub_item("Updating stack base branch to match your workflow");
447
448 stack.base_branch = current_branch.clone();
450
451 if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
453 stack_meta.base_branch = current_branch.clone();
454 stack_meta.set_current_branch(Some(current_branch.clone()));
455 }
456
457 println!(
458 " ā
Stack '{}' base branch updated to '{current_branch}'",
459 stack.name
460 );
461 }
462 }
463 }
464
465 if self.repo.branch_exists(&branch) {
468 } else {
470 self.repo
472 .create_branch(&branch, Some(&commit_hash))
473 .map_err(|e| {
474 CascadeError::branch(format!(
475 "Failed to create branch '{}' from commit {}: {}",
476 branch,
477 &commit_hash[..8],
478 e
479 ))
480 })?;
481
482 }
484
485 let entry_id = stack.push_entry(branch.clone(), commit_hash.clone(), message.clone());
487
488 let commit_metadata = CommitMetadata::new(
490 commit_hash.clone(),
491 message,
492 entry_id,
493 stack_id,
494 branch.clone(),
495 source_branch,
496 );
497
498 self.metadata.add_commit(commit_metadata);
500 if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
501 stack_meta.add_branch(branch);
502 stack_meta.add_commit(commit_hash);
503 }
504
505 self.save_to_disk()?;
506
507 Ok(entry_id)
508 }
509
510 pub fn pop_from_stack(&mut self) -> Result<StackEntry> {
512 let stack_id = self
513 .metadata
514 .active_stack_id
515 .ok_or_else(|| CascadeError::config("No active stack"))?;
516
517 let stack = self
518 .stacks
519 .get_mut(&stack_id)
520 .ok_or_else(|| CascadeError::config("Active stack not found"))?;
521
522 let entry = stack
523 .pop_entry()
524 .ok_or_else(|| CascadeError::config("Stack is empty"))?;
525
526 self.metadata.remove_commit(&entry.commit_hash);
528
529 if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
531 stack_meta.remove_commit(&entry.commit_hash);
532 }
534
535 self.save_to_disk()?;
536
537 Ok(entry)
538 }
539
540 pub fn submit_entry(
542 &mut self,
543 stack_id: &Uuid,
544 entry_id: &Uuid,
545 pull_request_id: String,
546 ) -> Result<()> {
547 let stack = self
548 .stacks
549 .get_mut(stack_id)
550 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
551
552 let entry_commit_hash = {
553 let entry = stack
554 .get_entry(entry_id)
555 .ok_or_else(|| CascadeError::config(format!("Entry {entry_id} not found")))?;
556 entry.commit_hash.clone()
557 };
558
559 if !stack.mark_entry_submitted(entry_id, pull_request_id.clone()) {
561 return Err(CascadeError::config(format!(
562 "Failed to mark entry {entry_id} as submitted"
563 )));
564 }
565
566 if let Some(commit_meta) = self.metadata.commits.get_mut(&entry_commit_hash) {
568 commit_meta.mark_submitted(pull_request_id);
569 }
570
571 if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
573 let submitted_count = stack.entries.iter().filter(|e| e.is_submitted).count();
574 stack_meta.update_stats(
575 stack.entries.len(),
576 submitted_count,
577 stack_meta.merged_commits,
578 );
579 }
580
581 self.save_to_disk()?;
582
583 Ok(())
584 }
585
586 pub fn repair_all_stacks(&mut self) -> Result<()> {
588 for stack in self.stacks.values_mut() {
589 stack.repair_data_consistency();
590 }
591 self.save_to_disk()?;
592 Ok(())
593 }
594
595 pub fn get_all_stacks(&self) -> Vec<&Stack> {
597 self.stacks.values().collect()
598 }
599
600 pub fn get_stack_metadata(&self, stack_id: &Uuid) -> Option<&StackMetadata> {
602 self.metadata.get_stack(stack_id)
603 }
604
605 pub fn get_repository_metadata(&self) -> &RepositoryMetadata {
607 &self.metadata
608 }
609
610 pub fn git_repo(&self) -> &GitRepository {
612 &self.repo
613 }
614
615 pub fn repo_path(&self) -> &Path {
617 &self.repo_path
618 }
619
620 pub fn is_in_edit_mode(&self) -> bool {
624 self.metadata
625 .edit_mode
626 .as_ref()
627 .map(|edit_state| edit_state.is_active)
628 .unwrap_or(false)
629 }
630
631 pub fn get_edit_mode_info(&self) -> Option<&super::metadata::EditModeState> {
633 self.metadata.edit_mode.as_ref()
634 }
635
636 pub fn enter_edit_mode(&mut self, stack_id: Uuid, entry_id: Uuid) -> Result<()> {
638 let commit_hash = {
640 let stack = self
641 .get_stack(&stack_id)
642 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
643
644 let entry = stack.get_entry(&entry_id).ok_or_else(|| {
645 CascadeError::config(format!("Entry {entry_id} not found in stack"))
646 })?;
647
648 entry.commit_hash.clone()
649 };
650
651 if self.is_in_edit_mode() {
653 self.exit_edit_mode()?;
654 }
655
656 let edit_state = super::metadata::EditModeState::new(stack_id, entry_id, commit_hash);
658
659 self.metadata.edit_mode = Some(edit_state);
660 self.save_to_disk()?;
661
662 debug!(
663 "Entered edit mode for entry {} in stack {}",
664 entry_id, stack_id
665 );
666 Ok(())
667 }
668
669 pub fn exit_edit_mode(&mut self) -> Result<()> {
671 if !self.is_in_edit_mode() {
672 return Err(CascadeError::config("Not currently in edit mode"));
673 }
674
675 self.metadata.edit_mode = None;
677 self.save_to_disk()?;
678
679 debug!("Exited edit mode");
680 Ok(())
681 }
682
683 pub fn sync_stack(&mut self, stack_id: &Uuid) -> Result<()> {
685 let stack = self
686 .stacks
687 .get_mut(stack_id)
688 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
689
690 if let Err(integrity_error) = stack.validate_git_integrity(&self.repo) {
692 stack.update_status(StackStatus::Corrupted);
693 return Err(CascadeError::branch(format!(
694 "Stack '{}' Git integrity check failed:\n{}",
695 stack.name, integrity_error
696 )));
697 }
698
699 let mut missing_commits = Vec::new();
701 for entry in &stack.entries {
702 if !self.repo.commit_exists(&entry.commit_hash)? {
703 missing_commits.push(entry.commit_hash.clone());
704 }
705 }
706
707 if !missing_commits.is_empty() {
708 stack.update_status(StackStatus::Corrupted);
709 return Err(CascadeError::branch(format!(
710 "Stack {} has missing commits: {}",
711 stack.name,
712 missing_commits.join(", ")
713 )));
714 }
715
716 if !self.repo.branch_exists_or_fetch(&stack.base_branch)? {
718 return Err(CascadeError::branch(format!(
719 "Base branch '{}' does not exist locally or remotely. Check the branch name or switch to a different base.",
720 stack.base_branch
721 )));
722 }
723
724 let _base_hash = self.repo.get_branch_head(&stack.base_branch)?;
725
726 let mut corrupted_entry = None;
728 for entry in &stack.entries {
729 if !self.repo.commit_exists(&entry.commit_hash)? {
730 corrupted_entry = Some((entry.commit_hash.clone(), entry.branch.clone()));
731 break;
732 }
733 }
734
735 if let Some((commit_hash, branch)) = corrupted_entry {
736 stack.update_status(StackStatus::Corrupted);
737 return Err(CascadeError::branch(format!(
738 "Commit {commit_hash} from stack entry '{branch}' no longer exists"
739 )));
740 }
741
742 let needs_sync = if let Some(first_entry) = stack.entries.first() {
744 match self
746 .repo
747 .get_commits_between(&stack.base_branch, &first_entry.commit_hash)
748 {
749 Ok(commits) => !commits.is_empty(), Err(_) => true, }
752 } else {
753 false };
755
756 if needs_sync {
758 stack.update_status(StackStatus::NeedsSync);
759 debug!(
760 "Stack '{}' needs sync - new commits on base branch",
761 stack.name
762 );
763 } else {
764 stack.update_status(StackStatus::Clean);
765 debug!("Stack '{}' is clean", stack.name);
766 }
767
768 if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
770 stack_meta.set_up_to_date(true);
771 }
772
773 self.save_to_disk()?;
774
775 Ok(())
776 }
777
778 pub fn list_stacks(&self) -> Vec<(Uuid, &str, &StackStatus, usize, Option<&str>)> {
780 self.stacks
781 .values()
782 .map(|stack| {
783 (
784 stack.id,
785 stack.name.as_str(),
786 &stack.status,
787 stack.entries.len(),
788 if stack.is_active {
789 Some("active")
790 } else {
791 None
792 },
793 )
794 })
795 .collect()
796 }
797
798 pub fn get_all_stacks_objects(&self) -> Result<Vec<Stack>> {
800 let mut stacks: Vec<Stack> = self.stacks.values().cloned().collect();
801 stacks.sort_by(|a, b| a.name.cmp(&b.name));
802 Ok(stacks)
803 }
804
805 pub fn validate_all(&self) -> Result<()> {
807 for stack in self.stacks.values() {
808 stack.validate().map_err(|e| {
810 CascadeError::config(format!("Stack '{}' validation failed: {}", stack.name, e))
811 })?;
812
813 stack.validate_git_integrity(&self.repo).map_err(|e| {
815 CascadeError::config(format!(
816 "Stack '{}' Git integrity validation failed: {}",
817 stack.name, e
818 ))
819 })?;
820 }
821 Ok(())
822 }
823
824 pub fn validate_stack(&self, stack_id: &Uuid) -> Result<()> {
826 let stack = self
827 .stacks
828 .get(stack_id)
829 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
830
831 stack.validate().map_err(|e| {
833 CascadeError::config(format!("Stack '{}' validation failed: {}", stack.name, e))
834 })?;
835
836 stack.validate_git_integrity(&self.repo).map_err(|e| {
838 CascadeError::config(format!(
839 "Stack '{}' Git integrity validation failed: {}",
840 stack.name, e
841 ))
842 })?;
843
844 Ok(())
845 }
846
847 pub fn save_to_disk(&self) -> Result<()> {
849 if !self.config_dir.exists() {
851 fs::create_dir_all(&self.config_dir).map_err(|e| {
852 CascadeError::config(format!("Failed to create config directory: {e}"))
853 })?;
854 }
855
856 crate::utils::atomic_file::write_json(&self.stacks_file, &self.stacks)?;
858
859 crate::utils::atomic_file::write_json(&self.metadata_file, &self.metadata)?;
861
862 Ok(())
863 }
864
865 fn load_from_disk(&mut self) -> Result<()> {
867 if self.stacks_file.exists() {
869 let stacks_content = fs::read_to_string(&self.stacks_file)
870 .map_err(|e| CascadeError::config(format!("Failed to read stacks file: {e}")))?;
871
872 self.stacks = serde_json::from_str(&stacks_content)
873 .map_err(|e| CascadeError::config(format!("Failed to parse stacks file: {e}")))?;
874 }
875
876 if self.metadata_file.exists() {
878 let metadata_content = fs::read_to_string(&self.metadata_file)
879 .map_err(|e| CascadeError::config(format!("Failed to read metadata file: {e}")))?;
880
881 self.metadata = serde_json::from_str(&metadata_content)
882 .map_err(|e| CascadeError::config(format!("Failed to parse metadata file: {e}")))?;
883 }
884
885 Ok(())
886 }
887
888 pub fn check_for_branch_change(&mut self) -> Result<bool> {
891 let (stack_id, stack_name, stored_branch) = {
893 if let Some(active_stack) = self.get_active_stack() {
894 let stack_id = active_stack.id;
895 let stack_name = active_stack.name.clone();
896 let stored_branch = if let Some(stack_meta) = self.metadata.get_stack(&stack_id) {
897 stack_meta.current_branch.clone()
898 } else {
899 None
900 };
901 (Some(stack_id), stack_name, stored_branch)
902 } else {
903 (None, String::new(), None)
904 }
905 };
906
907 let Some(stack_id) = stack_id else {
909 return Ok(true);
910 };
911
912 let current_branch = self.repo.get_current_branch().ok();
913
914 if stored_branch.as_ref() != current_branch.as_ref() {
916 Output::warning("Branch change detected!");
917 Output::sub_item(format!(
918 "Stack '{}' was active on: {}",
919 stack_name,
920 stored_branch.as_deref().unwrap_or("unknown")
921 ));
922 Output::sub_item(format!(
923 "Current branch: {}",
924 current_branch.as_deref().unwrap_or("unknown")
925 ));
926 Output::spacing();
927
928 let options = vec![
929 format!("Keep stack '{stack_name}' active (continue with stack workflow)"),
930 "Deactivate stack (use normal Git workflow)".to_string(),
931 "Switch to a different stack".to_string(),
932 "Cancel and stay on current workflow".to_string(),
933 ];
934
935 let choice = Select::with_theme(&ColorfulTheme::default())
936 .with_prompt("What would you like to do?")
937 .default(0)
938 .items(&options)
939 .interact()
940 .map_err(|e| CascadeError::config(format!("Failed to get user choice: {e}")))?;
941
942 match choice {
943 0 => {
944 if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
946 stack_meta.set_current_branch(current_branch);
947 }
948 self.save_to_disk()?;
949 Output::success(format!(
950 "Continuing with stack '{stack_name}' on current branch"
951 ));
952 return Ok(true);
953 }
954 1 => {
955 self.set_active_stack(None)?;
957 Output::success(format!(
958 "Deactivated stack '{stack_name}' - using normal Git workflow"
959 ));
960 return Ok(false);
961 }
962 2 => {
963 let stacks = self.get_all_stacks();
965 if stacks.len() <= 1 {
966 Output::warning("No other stacks available. Deactivating current stack.");
967 self.set_active_stack(None)?;
968 return Ok(false);
969 }
970
971 Output::spacing();
972 Output::info("Available stacks:");
973 for (i, stack) in stacks.iter().enumerate() {
974 if stack.id != stack_id {
975 Output::numbered_item(i + 1, &stack.name);
976 }
977 }
978 let stack_name_input: String = Input::with_theme(&ColorfulTheme::default())
979 .with_prompt("Enter stack name")
980 .validate_with(|input: &String| -> std::result::Result<(), &str> {
981 if input.trim().is_empty() {
982 Err("Stack name cannot be empty")
983 } else {
984 Ok(())
985 }
986 })
987 .interact_text()
988 .map_err(|e| {
989 CascadeError::config(format!("Failed to get user input: {e}"))
990 })?;
991 let stack_name_input = stack_name_input.trim();
992
993 if let Err(e) = self.set_active_stack_by_name(stack_name_input) {
994 Output::warning(format!("{e}"));
995 Output::sub_item("Deactivating stack instead.");
996 self.set_active_stack(None)?;
997 return Ok(false);
998 } else {
999 Output::success(format!("Switched to stack '{stack_name_input}'"));
1000 return Ok(true);
1001 }
1002 }
1003 3 => {
1004 Output::info("Cancelled - no changes made");
1005 return Ok(false);
1006 }
1007 _ => {
1008 Output::info("Invalid choice - no changes made");
1009 return Ok(false);
1010 }
1011 }
1012 }
1013
1014 Ok(true)
1016 }
1017
1018 pub fn handle_branch_modifications(
1021 &mut self,
1022 stack_id: &Uuid,
1023 auto_mode: Option<String>,
1024 ) -> Result<()> {
1025 let stack = self
1026 .stacks
1027 .get_mut(stack_id)
1028 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
1029
1030 debug!("Checking Git integrity for stack '{}'", stack.name);
1031
1032 let mut modifications = Vec::new();
1034 for entry in &stack.entries {
1035 if !self.repo.branch_exists(&entry.branch) {
1036 modifications.push(BranchModification::Missing {
1037 branch: entry.branch.clone(),
1038 entry_id: entry.id,
1039 expected_commit: entry.commit_hash.clone(),
1040 });
1041 } else if let Ok(branch_head) = self.repo.get_branch_head(&entry.branch) {
1042 if branch_head != entry.commit_hash {
1043 let extra_commits = self
1045 .repo
1046 .get_commits_between(&entry.commit_hash, &branch_head)?;
1047 let mut extra_messages = Vec::new();
1048 for commit in &extra_commits {
1049 if let Some(message) = commit.message() {
1050 let first_line =
1051 message.lines().next().unwrap_or("(no message)").to_string();
1052 extra_messages.push(format!(
1053 "{}: {}",
1054 &commit.id().to_string()[..8],
1055 first_line
1056 ));
1057 }
1058 }
1059
1060 modifications.push(BranchModification::ExtraCommits {
1061 branch: entry.branch.clone(),
1062 entry_id: entry.id,
1063 expected_commit: entry.commit_hash.clone(),
1064 actual_commit: branch_head,
1065 extra_commit_count: extra_commits.len(),
1066 extra_commit_messages: extra_messages,
1067 });
1068 }
1069 }
1070 }
1071
1072 if modifications.is_empty() {
1073 return Ok(());
1075 }
1076
1077 println!();
1079 Output::section(format!("Branch modifications detected in '{}'", stack.name));
1080 for (i, modification) in modifications.iter().enumerate() {
1081 match modification {
1082 BranchModification::Missing { branch, .. } => {
1083 Output::numbered_item(i + 1, format!("Branch '{branch}' is missing"));
1084 }
1085 BranchModification::ExtraCommits {
1086 branch,
1087 expected_commit,
1088 actual_commit,
1089 extra_commit_count,
1090 extra_commit_messages,
1091 ..
1092 } => {
1093 println!(
1094 " {}. Branch '{}' has {} extra commit(s)",
1095 i + 1,
1096 branch,
1097 extra_commit_count
1098 );
1099 println!(
1100 " Expected: {} | Actual: {}",
1101 &expected_commit[..8],
1102 &actual_commit[..8]
1103 );
1104
1105 for (j, message) in extra_commit_messages.iter().enumerate() {
1107 match j.cmp(&3) {
1108 std::cmp::Ordering::Less => {
1109 Output::sub_item(format!(" + {message}"));
1110 }
1111 std::cmp::Ordering::Equal => {
1112 Output::sub_item(format!(
1113 " + ... and {} more",
1114 extra_commit_count - 3
1115 ));
1116 break;
1117 }
1118 std::cmp::Ordering::Greater => {
1119 break;
1120 }
1121 }
1122 }
1123 }
1124 }
1125 }
1126 Output::spacing();
1127
1128 if let Some(mode) = auto_mode {
1130 return self.apply_auto_fix(stack_id, &modifications, &mode);
1131 }
1132
1133 let mut handled_count = 0;
1135 let mut skipped_count = 0;
1136 for modification in modifications.iter() {
1137 let was_skipped = self.handle_single_modification(stack_id, modification)?;
1138 if was_skipped {
1139 skipped_count += 1;
1140 } else {
1141 handled_count += 1;
1142 }
1143 }
1144
1145 self.save_to_disk()?;
1146
1147 if skipped_count == 0 {
1149 Output::success("All branch modifications resolved");
1150 } else if handled_count > 0 {
1151 Output::warning(format!(
1152 "Resolved {} modification(s), {} skipped",
1153 handled_count, skipped_count
1154 ));
1155 } else {
1156 Output::warning("All modifications skipped - integrity issues remain");
1157 }
1158
1159 Ok(())
1160 }
1161
1162 fn handle_single_modification(
1165 &mut self,
1166 stack_id: &Uuid,
1167 modification: &BranchModification,
1168 ) -> Result<bool> {
1169 match modification {
1170 BranchModification::Missing {
1171 branch,
1172 expected_commit,
1173 ..
1174 } => {
1175 Output::info(format!("Missing branch '{branch}'"));
1176 Output::sub_item(format!(
1177 "Will create the branch at commit {}",
1178 &expected_commit[..8]
1179 ));
1180
1181 self.repo.create_branch(branch, Some(expected_commit))?;
1182 Output::success(format!("Created branch '{branch}'"));
1183 Ok(false) }
1185
1186 BranchModification::ExtraCommits {
1187 branch,
1188 entry_id,
1189 expected_commit,
1190 extra_commit_count,
1191 ..
1192 } => {
1193 println!();
1194 Output::info(format!(
1195 "Branch '{}' has {} extra commit(s)",
1196 branch, extra_commit_count
1197 ));
1198 let options = vec![
1199 "Incorporate - Update stack entry to include extra commits",
1200 "Split - Create new stack entry for extra commits",
1201 "Reset - Remove extra commits (DESTRUCTIVE)",
1202 "Skip - Leave as-is for now",
1203 ];
1204
1205 let choice = Select::with_theme(&ColorfulTheme::default())
1206 .with_prompt("Choose how to handle extra commits")
1207 .default(0)
1208 .items(&options)
1209 .interact()
1210 .map_err(|e| CascadeError::config(format!("Failed to get user choice: {e}")))?;
1211
1212 match choice {
1213 0 => {
1214 self.incorporate_extra_commits(stack_id, *entry_id, branch)?;
1215 Ok(false) }
1217 1 => {
1218 self.split_extra_commits(stack_id, *entry_id, branch)?;
1219 Ok(false) }
1221 2 => {
1222 self.reset_branch_destructive(branch, expected_commit)?;
1223 Ok(false) }
1225 3 => {
1226 Output::warning(format!("Skipped '{branch}' - integrity issue remains"));
1227 Ok(true) }
1229 _ => {
1230 Output::warning(format!("Invalid choice - skipped '{branch}'"));
1231 Ok(true) }
1233 }
1234 }
1235 }
1236 }
1237
1238 fn apply_auto_fix(
1240 &mut self,
1241 stack_id: &Uuid,
1242 modifications: &[BranchModification],
1243 mode: &str,
1244 ) -> Result<()> {
1245 Output::info(format!("š¤ Applying automatic fix mode: {mode}"));
1246
1247 for modification in modifications {
1248 match (modification, mode) {
1249 (
1250 BranchModification::Missing {
1251 branch,
1252 expected_commit,
1253 ..
1254 },
1255 _,
1256 ) => {
1257 self.repo.create_branch(branch, Some(expected_commit))?;
1258 Output::success(format!("Created missing branch '{branch}'"));
1259 }
1260
1261 (
1262 BranchModification::ExtraCommits {
1263 branch, entry_id, ..
1264 },
1265 "incorporate",
1266 ) => {
1267 self.incorporate_extra_commits(stack_id, *entry_id, branch)?;
1268 }
1269
1270 (
1271 BranchModification::ExtraCommits {
1272 branch, entry_id, ..
1273 },
1274 "split",
1275 ) => {
1276 self.split_extra_commits(stack_id, *entry_id, branch)?;
1277 }
1278
1279 (
1280 BranchModification::ExtraCommits {
1281 branch,
1282 expected_commit,
1283 ..
1284 },
1285 "reset",
1286 ) => {
1287 self.reset_branch_destructive(branch, expected_commit)?;
1288 }
1289
1290 _ => {
1291 return Err(CascadeError::config(format!(
1292 "Unknown auto-fix mode '{mode}'. Use: incorporate, split, reset"
1293 )));
1294 }
1295 }
1296 }
1297
1298 self.save_to_disk()?;
1299 Output::success(format!("Auto-fix completed for mode: {mode}"));
1300 Ok(())
1301 }
1302
1303 fn incorporate_extra_commits(
1305 &mut self,
1306 stack_id: &Uuid,
1307 entry_id: Uuid,
1308 branch: &str,
1309 ) -> Result<()> {
1310 let stack = self
1311 .stacks
1312 .get_mut(stack_id)
1313 .ok_or_else(|| CascadeError::config(format!("Stack not found: {}", stack_id)))?;
1314
1315 let entry_info = stack
1317 .entries
1318 .iter()
1319 .find(|e| e.id == entry_id)
1320 .map(|e| (e.commit_hash.clone(), e.id));
1321
1322 if let Some((old_commit_hash, entry_id)) = entry_info {
1323 let new_head = self.repo.get_branch_head(branch)?;
1324 let old_commit = old_commit_hash[..8].to_string();
1325
1326 let extra_commits = self.repo.get_commits_between(&old_commit_hash, &new_head)?;
1328
1329 stack
1333 .update_entry_commit_hash(&entry_id, new_head.clone())
1334 .map_err(CascadeError::config)?;
1335
1336 Output::success(format!(
1337 "Incorporated {} commit(s) into entry '{}'",
1338 extra_commits.len(),
1339 &new_head[..8]
1340 ));
1341 Output::sub_item(format!("Updated: {} -> {}", old_commit, &new_head[..8]));
1342 }
1343
1344 Ok(())
1345 }
1346
1347 fn split_extra_commits(&mut self, stack_id: &Uuid, entry_id: Uuid, branch: &str) -> Result<()> {
1349 let stack = self
1350 .stacks
1351 .get_mut(stack_id)
1352 .ok_or_else(|| CascadeError::config(format!("Stack not found: {}", stack_id)))?;
1353 let new_head = self.repo.get_branch_head(branch)?;
1354
1355 let entry_position = stack
1357 .entries
1358 .iter()
1359 .position(|e| e.id == entry_id)
1360 .ok_or_else(|| CascadeError::config("Entry not found in stack"))?;
1361
1362 let base_name = branch.trim_end_matches(|c: char| c.is_ascii_digit() || c == '-');
1364 let new_branch = format!("{base_name}-continued");
1365
1366 self.repo.create_branch(&new_branch, Some(&new_head))?;
1368
1369 let original_entry = &stack.entries[entry_position];
1371 let original_commit_hash = original_entry.commit_hash.clone(); let extra_commits = self
1373 .repo
1374 .get_commits_between(&original_commit_hash, &new_head)?;
1375
1376 let mut extra_messages = Vec::new();
1378 for commit in &extra_commits {
1379 if let Some(message) = commit.message() {
1380 let first_line = message.lines().next().unwrap_or("").to_string();
1381 extra_messages.push(first_line);
1382 }
1383 }
1384
1385 let new_message = if extra_messages.len() == 1 {
1386 extra_messages[0].clone()
1387 } else {
1388 format!("Combined changes:\n⢠{}", extra_messages.join("\n⢠"))
1389 };
1390
1391 let now = chrono::Utc::now();
1393 let new_entry = crate::stack::StackEntry {
1394 id: uuid::Uuid::new_v4(),
1395 branch: new_branch.clone(),
1396 commit_hash: new_head,
1397 message: new_message,
1398 parent_id: Some(entry_id), children: Vec::new(),
1400 created_at: now,
1401 updated_at: now,
1402 is_submitted: false,
1403 pull_request_id: None,
1404 is_synced: false,
1405 };
1406
1407 stack.entries.insert(entry_position + 1, new_entry);
1409
1410 self.repo
1412 .reset_branch_to_commit(branch, &original_commit_hash)?;
1413
1414 println!(
1415 " ā
Split {} commit(s) into new entry '{}'",
1416 extra_commits.len(),
1417 new_branch
1418 );
1419 println!(" Original branch '{branch}' reset to expected commit");
1420
1421 Ok(())
1422 }
1423
1424 fn reset_branch_destructive(&self, branch: &str, expected_commit: &str) -> Result<()> {
1426 self.repo.reset_branch_to_commit(branch, expected_commit)?;
1427 Output::warning(format!(
1428 "Reset branch '{}' to {} (extra commits lost)",
1429 branch,
1430 &expected_commit[..8]
1431 ));
1432 Ok(())
1433 }
1434}
1435
1436#[cfg(test)]
1437mod tests {
1438 use super::*;
1439 use std::process::Command;
1440 use tempfile::TempDir;
1441
1442 fn create_test_repo() -> (TempDir, PathBuf) {
1443 let temp_dir = TempDir::new().unwrap();
1444 let repo_path = temp_dir.path().to_path_buf();
1445
1446 Command::new("git")
1448 .args(["init"])
1449 .current_dir(&repo_path)
1450 .output()
1451 .unwrap();
1452
1453 Command::new("git")
1455 .args(["config", "user.name", "Test User"])
1456 .current_dir(&repo_path)
1457 .output()
1458 .unwrap();
1459
1460 Command::new("git")
1461 .args(["config", "user.email", "test@example.com"])
1462 .current_dir(&repo_path)
1463 .output()
1464 .unwrap();
1465
1466 std::fs::write(repo_path.join("README.md"), "# Test Repo").unwrap();
1468 Command::new("git")
1469 .args(["add", "."])
1470 .current_dir(&repo_path)
1471 .output()
1472 .unwrap();
1473
1474 Command::new("git")
1475 .args(["commit", "-m", "Initial commit"])
1476 .current_dir(&repo_path)
1477 .output()
1478 .unwrap();
1479
1480 crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
1482 .unwrap();
1483
1484 (temp_dir, repo_path)
1485 }
1486
1487 #[test]
1488 fn test_create_stack_manager() {
1489 let (_temp_dir, repo_path) = create_test_repo();
1490 let manager = StackManager::new(&repo_path).unwrap();
1491
1492 assert_eq!(manager.stacks.len(), 0);
1493 assert!(manager.get_active_stack().is_none());
1494 }
1495
1496 #[test]
1497 fn test_create_and_manage_stack() {
1498 let (_temp_dir, repo_path) = create_test_repo();
1499 let mut manager = StackManager::new(&repo_path).unwrap();
1500
1501 let stack_id = manager
1503 .create_stack(
1504 "test-stack".to_string(),
1505 None, Some("Test stack description".to_string()),
1507 )
1508 .unwrap();
1509
1510 assert_eq!(manager.stacks.len(), 1);
1512 let stack = manager.get_stack(&stack_id).unwrap();
1513 assert_eq!(stack.name, "test-stack");
1514 assert!(!stack.base_branch.is_empty());
1516 assert!(stack.is_active);
1517
1518 let active = manager.get_active_stack().unwrap();
1520 assert_eq!(active.id, stack_id);
1521
1522 let found = manager.get_stack_by_name("test-stack").unwrap();
1524 assert_eq!(found.id, stack_id);
1525 }
1526
1527 #[test]
1528 fn test_stack_persistence() {
1529 let (_temp_dir, repo_path) = create_test_repo();
1530
1531 let stack_id = {
1532 let mut manager = StackManager::new(&repo_path).unwrap();
1533 manager
1534 .create_stack("persistent-stack".to_string(), None, None)
1535 .unwrap()
1536 };
1537
1538 let manager = StackManager::new(&repo_path).unwrap();
1540 assert_eq!(manager.stacks.len(), 1);
1541 let stack = manager.get_stack(&stack_id).unwrap();
1542 assert_eq!(stack.name, "persistent-stack");
1543 }
1544
1545 #[test]
1546 fn test_multiple_stacks() {
1547 let (_temp_dir, repo_path) = create_test_repo();
1548 let mut manager = StackManager::new(&repo_path).unwrap();
1549
1550 let stack1_id = manager
1551 .create_stack("stack-1".to_string(), None, None)
1552 .unwrap();
1553 let stack2_id = manager
1554 .create_stack("stack-2".to_string(), None, None)
1555 .unwrap();
1556
1557 assert_eq!(manager.stacks.len(), 2);
1558
1559 assert!(!manager.get_stack(&stack1_id).unwrap().is_active);
1561 assert!(manager.get_stack(&stack2_id).unwrap().is_active);
1562
1563 manager.set_active_stack(Some(stack2_id)).unwrap();
1565 assert!(!manager.get_stack(&stack1_id).unwrap().is_active);
1566 assert!(manager.get_stack(&stack2_id).unwrap().is_active);
1567 }
1568
1569 #[test]
1570 fn test_delete_stack() {
1571 let (_temp_dir, repo_path) = create_test_repo();
1572 let mut manager = StackManager::new(&repo_path).unwrap();
1573
1574 let stack_id = manager
1575 .create_stack("to-delete".to_string(), None, None)
1576 .unwrap();
1577 assert_eq!(manager.stacks.len(), 1);
1578
1579 let deleted = manager.delete_stack(&stack_id).unwrap();
1580 assert_eq!(deleted.name, "to-delete");
1581 assert_eq!(manager.stacks.len(), 0);
1582 assert!(manager.get_active_stack().is_none());
1583 }
1584
1585 #[test]
1586 fn test_validation() {
1587 let (_temp_dir, repo_path) = create_test_repo();
1588 let mut manager = StackManager::new(&repo_path).unwrap();
1589
1590 manager
1591 .create_stack("valid-stack".to_string(), None, None)
1592 .unwrap();
1593
1594 assert!(manager.validate_all().is_ok());
1596 }
1597
1598 #[test]
1599 fn test_duplicate_commit_message_detection() {
1600 let (_temp_dir, repo_path) = create_test_repo();
1601 let mut manager = StackManager::new(&repo_path).unwrap();
1602
1603 manager
1605 .create_stack("test-stack".to_string(), None, None)
1606 .unwrap();
1607
1608 std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1610 Command::new("git")
1611 .args(["add", "file1.txt"])
1612 .current_dir(&repo_path)
1613 .output()
1614 .unwrap();
1615
1616 Command::new("git")
1617 .args(["commit", "-m", "Add authentication feature"])
1618 .current_dir(&repo_path)
1619 .output()
1620 .unwrap();
1621
1622 let commit1_hash = Command::new("git")
1623 .args(["rev-parse", "HEAD"])
1624 .current_dir(&repo_path)
1625 .output()
1626 .unwrap();
1627 let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1628 .trim()
1629 .to_string();
1630
1631 let entry1_id = manager
1633 .push_to_stack(
1634 "feature/auth".to_string(),
1635 commit1_hash,
1636 "Add authentication feature".to_string(),
1637 "main".to_string(),
1638 )
1639 .unwrap();
1640
1641 assert!(manager
1643 .get_active_stack()
1644 .unwrap()
1645 .get_entry(&entry1_id)
1646 .is_some());
1647
1648 std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1650 Command::new("git")
1651 .args(["add", "file2.txt"])
1652 .current_dir(&repo_path)
1653 .output()
1654 .unwrap();
1655
1656 Command::new("git")
1657 .args(["commit", "-m", "Different commit message"])
1658 .current_dir(&repo_path)
1659 .output()
1660 .unwrap();
1661
1662 let commit2_hash = Command::new("git")
1663 .args(["rev-parse", "HEAD"])
1664 .current_dir(&repo_path)
1665 .output()
1666 .unwrap();
1667 let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1668 .trim()
1669 .to_string();
1670
1671 let result = manager.push_to_stack(
1673 "feature/auth2".to_string(),
1674 commit2_hash.clone(),
1675 "Add authentication feature".to_string(), "main".to_string(),
1677 );
1678
1679 assert!(result.is_err());
1681 let error = result.unwrap_err();
1682 assert!(matches!(error, CascadeError::Validation(_)));
1683
1684 let error_msg = error.to_string();
1686 assert!(error_msg.contains("Duplicate commit message"));
1687 assert!(error_msg.contains("Add authentication feature"));
1688 assert!(error_msg.contains("š” Consider using a more specific message"));
1689
1690 let entry2_id = manager
1692 .push_to_stack(
1693 "feature/auth2".to_string(),
1694 commit2_hash,
1695 "Add authentication validation".to_string(), "main".to_string(),
1697 )
1698 .unwrap();
1699
1700 let stack = manager.get_active_stack().unwrap();
1702 assert_eq!(stack.entries.len(), 2);
1703 assert!(stack.get_entry(&entry1_id).is_some());
1704 assert!(stack.get_entry(&entry2_id).is_some());
1705 }
1706
1707 #[test]
1708 fn test_duplicate_message_with_different_case() {
1709 let (_temp_dir, repo_path) = create_test_repo();
1710 let mut manager = StackManager::new(&repo_path).unwrap();
1711
1712 manager
1713 .create_stack("test-stack".to_string(), None, None)
1714 .unwrap();
1715
1716 std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1718 Command::new("git")
1719 .args(["add", "file1.txt"])
1720 .current_dir(&repo_path)
1721 .output()
1722 .unwrap();
1723
1724 Command::new("git")
1725 .args(["commit", "-m", "fix bug"])
1726 .current_dir(&repo_path)
1727 .output()
1728 .unwrap();
1729
1730 let commit1_hash = Command::new("git")
1731 .args(["rev-parse", "HEAD"])
1732 .current_dir(&repo_path)
1733 .output()
1734 .unwrap();
1735 let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1736 .trim()
1737 .to_string();
1738
1739 manager
1740 .push_to_stack(
1741 "feature/fix1".to_string(),
1742 commit1_hash,
1743 "fix bug".to_string(),
1744 "main".to_string(),
1745 )
1746 .unwrap();
1747
1748 std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1750 Command::new("git")
1751 .args(["add", "file2.txt"])
1752 .current_dir(&repo_path)
1753 .output()
1754 .unwrap();
1755
1756 Command::new("git")
1757 .args(["commit", "-m", "Fix Bug"])
1758 .current_dir(&repo_path)
1759 .output()
1760 .unwrap();
1761
1762 let commit2_hash = Command::new("git")
1763 .args(["rev-parse", "HEAD"])
1764 .current_dir(&repo_path)
1765 .output()
1766 .unwrap();
1767 let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1768 .trim()
1769 .to_string();
1770
1771 let result = manager.push_to_stack(
1773 "feature/fix2".to_string(),
1774 commit2_hash,
1775 "Fix Bug".to_string(), "main".to_string(),
1777 );
1778
1779 assert!(result.is_ok());
1781 }
1782
1783 #[test]
1784 fn test_duplicate_message_across_different_stacks() {
1785 let (_temp_dir, repo_path) = create_test_repo();
1786 let mut manager = StackManager::new(&repo_path).unwrap();
1787
1788 let stack1_id = manager
1790 .create_stack("stack1".to_string(), None, None)
1791 .unwrap();
1792
1793 std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1794 Command::new("git")
1795 .args(["add", "file1.txt"])
1796 .current_dir(&repo_path)
1797 .output()
1798 .unwrap();
1799
1800 Command::new("git")
1801 .args(["commit", "-m", "shared message"])
1802 .current_dir(&repo_path)
1803 .output()
1804 .unwrap();
1805
1806 let commit1_hash = Command::new("git")
1807 .args(["rev-parse", "HEAD"])
1808 .current_dir(&repo_path)
1809 .output()
1810 .unwrap();
1811 let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1812 .trim()
1813 .to_string();
1814
1815 manager
1816 .push_to_stack(
1817 "feature/shared1".to_string(),
1818 commit1_hash,
1819 "shared message".to_string(),
1820 "main".to_string(),
1821 )
1822 .unwrap();
1823
1824 let stack2_id = manager
1826 .create_stack("stack2".to_string(), None, None)
1827 .unwrap();
1828
1829 manager.set_active_stack(Some(stack2_id)).unwrap();
1831
1832 std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1834 Command::new("git")
1835 .args(["add", "file2.txt"])
1836 .current_dir(&repo_path)
1837 .output()
1838 .unwrap();
1839
1840 Command::new("git")
1841 .args(["commit", "-m", "shared message"])
1842 .current_dir(&repo_path)
1843 .output()
1844 .unwrap();
1845
1846 let commit2_hash = Command::new("git")
1847 .args(["rev-parse", "HEAD"])
1848 .current_dir(&repo_path)
1849 .output()
1850 .unwrap();
1851 let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1852 .trim()
1853 .to_string();
1854
1855 let result = manager.push_to_stack(
1857 "feature/shared2".to_string(),
1858 commit2_hash,
1859 "shared message".to_string(), "main".to_string(),
1861 );
1862
1863 assert!(result.is_ok());
1865
1866 let stack1 = manager.get_stack(&stack1_id).unwrap();
1868 let stack2 = manager.get_stack(&stack2_id).unwrap();
1869
1870 assert_eq!(stack1.entries.len(), 1);
1871 assert_eq!(stack2.entries.len(), 1);
1872 assert_eq!(stack1.entries[0].message, "shared message");
1873 assert_eq!(stack2.entries[0].message, "shared message");
1874 }
1875
1876 #[test]
1877 fn test_duplicate_after_pop() {
1878 let (_temp_dir, repo_path) = create_test_repo();
1879 let mut manager = StackManager::new(&repo_path).unwrap();
1880
1881 manager
1882 .create_stack("test-stack".to_string(), None, None)
1883 .unwrap();
1884
1885 std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1887 Command::new("git")
1888 .args(["add", "file1.txt"])
1889 .current_dir(&repo_path)
1890 .output()
1891 .unwrap();
1892
1893 Command::new("git")
1894 .args(["commit", "-m", "temporary message"])
1895 .current_dir(&repo_path)
1896 .output()
1897 .unwrap();
1898
1899 let commit1_hash = Command::new("git")
1900 .args(["rev-parse", "HEAD"])
1901 .current_dir(&repo_path)
1902 .output()
1903 .unwrap();
1904 let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1905 .trim()
1906 .to_string();
1907
1908 manager
1909 .push_to_stack(
1910 "feature/temp".to_string(),
1911 commit1_hash,
1912 "temporary message".to_string(),
1913 "main".to_string(),
1914 )
1915 .unwrap();
1916
1917 let popped = manager.pop_from_stack().unwrap();
1919 assert_eq!(popped.message, "temporary message");
1920
1921 std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1923 Command::new("git")
1924 .args(["add", "file2.txt"])
1925 .current_dir(&repo_path)
1926 .output()
1927 .unwrap();
1928
1929 Command::new("git")
1930 .args(["commit", "-m", "temporary message"])
1931 .current_dir(&repo_path)
1932 .output()
1933 .unwrap();
1934
1935 let commit2_hash = Command::new("git")
1936 .args(["rev-parse", "HEAD"])
1937 .current_dir(&repo_path)
1938 .output()
1939 .unwrap();
1940 let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1941 .trim()
1942 .to_string();
1943
1944 let result = manager.push_to_stack(
1946 "feature/temp2".to_string(),
1947 commit2_hash,
1948 "temporary message".to_string(),
1949 "main".to_string(),
1950 );
1951
1952 assert!(result.is_ok());
1953 }
1954}