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