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