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