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