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