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 pub 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
1226 .stacks
1227 .get_mut(stack_id)
1228 .ok_or_else(|| CascadeError::config(format!("Stack not found: {}", stack_id)))?;
1229
1230 if let Some(entry) = stack.entries.iter_mut().find(|e| e.id == entry_id) {
1231 let new_head = self.repo.get_branch_head(branch)?;
1232 let old_commit = entry.commit_hash[..8].to_string(); let extra_commits = self
1236 .repo
1237 .get_commits_between(&entry.commit_hash, &new_head)?;
1238
1239 entry.commit_hash = new_head.clone();
1241
1242 let mut extra_messages = Vec::new();
1244 for commit in &extra_commits {
1245 if let Some(message) = commit.message() {
1246 let first_line = message.lines().next().unwrap_or("").to_string();
1247 extra_messages.push(first_line);
1248 }
1249 }
1250
1251 if !extra_messages.is_empty() {
1252 entry.message = format!(
1253 "{}\n\nIncorporated commits:\n⢠{}",
1254 entry.message,
1255 extra_messages.join("\n⢠")
1256 );
1257 }
1258
1259 Output::success(format!(
1260 "Incorporated {} commit(s) into entry '{}'",
1261 extra_commits.len(),
1262 entry.short_hash()
1263 ));
1264 Output::sub_item(format!("Updated: {} -> {}", old_commit, &new_head[..8]));
1265 }
1266
1267 Ok(())
1268 }
1269
1270 fn split_extra_commits(&mut self, stack_id: &Uuid, entry_id: Uuid, branch: &str) -> Result<()> {
1272 let stack = self
1273 .stacks
1274 .get_mut(stack_id)
1275 .ok_or_else(|| CascadeError::config(format!("Stack not found: {}", stack_id)))?;
1276 let new_head = self.repo.get_branch_head(branch)?;
1277
1278 let entry_position = stack
1280 .entries
1281 .iter()
1282 .position(|e| e.id == entry_id)
1283 .ok_or_else(|| CascadeError::config("Entry not found in stack"))?;
1284
1285 let base_name = branch.trim_end_matches(|c: char| c.is_ascii_digit() || c == '-');
1287 let new_branch = format!("{base_name}-continued");
1288
1289 self.repo.create_branch(&new_branch, Some(&new_head))?;
1291
1292 let original_entry = &stack.entries[entry_position];
1294 let original_commit_hash = original_entry.commit_hash.clone(); let extra_commits = self
1296 .repo
1297 .get_commits_between(&original_commit_hash, &new_head)?;
1298
1299 let mut extra_messages = Vec::new();
1301 for commit in &extra_commits {
1302 if let Some(message) = commit.message() {
1303 let first_line = message.lines().next().unwrap_or("").to_string();
1304 extra_messages.push(first_line);
1305 }
1306 }
1307
1308 let new_message = if extra_messages.len() == 1 {
1309 extra_messages[0].clone()
1310 } else {
1311 format!("Combined changes:\n⢠{}", extra_messages.join("\n⢠"))
1312 };
1313
1314 let now = chrono::Utc::now();
1316 let new_entry = crate::stack::StackEntry {
1317 id: uuid::Uuid::new_v4(),
1318 branch: new_branch.clone(),
1319 commit_hash: new_head,
1320 message: new_message,
1321 parent_id: Some(entry_id), children: Vec::new(),
1323 created_at: now,
1324 updated_at: now,
1325 is_submitted: false,
1326 pull_request_id: None,
1327 is_synced: false,
1328 };
1329
1330 stack.entries.insert(entry_position + 1, new_entry);
1332
1333 self.repo
1335 .reset_branch_to_commit(branch, &original_commit_hash)?;
1336
1337 println!(
1338 " ā
Split {} commit(s) into new entry '{}'",
1339 extra_commits.len(),
1340 new_branch
1341 );
1342 println!(" Original branch '{branch}' reset to expected commit");
1343
1344 Ok(())
1345 }
1346
1347 fn reset_branch_destructive(&self, branch: &str, expected_commit: &str) -> Result<()> {
1349 self.repo.reset_branch_to_commit(branch, expected_commit)?;
1350 Output::warning(format!(
1351 "Reset branch '{}' to {} (extra commits lost)",
1352 branch,
1353 &expected_commit[..8]
1354 ));
1355 Ok(())
1356 }
1357}
1358
1359#[cfg(test)]
1360mod tests {
1361 use super::*;
1362 use std::process::Command;
1363 use tempfile::TempDir;
1364
1365 fn create_test_repo() -> (TempDir, PathBuf) {
1366 let temp_dir = TempDir::new().unwrap();
1367 let repo_path = temp_dir.path().to_path_buf();
1368
1369 Command::new("git")
1371 .args(["init"])
1372 .current_dir(&repo_path)
1373 .output()
1374 .unwrap();
1375
1376 Command::new("git")
1378 .args(["config", "user.name", "Test User"])
1379 .current_dir(&repo_path)
1380 .output()
1381 .unwrap();
1382
1383 Command::new("git")
1384 .args(["config", "user.email", "test@example.com"])
1385 .current_dir(&repo_path)
1386 .output()
1387 .unwrap();
1388
1389 std::fs::write(repo_path.join("README.md"), "# Test Repo").unwrap();
1391 Command::new("git")
1392 .args(["add", "."])
1393 .current_dir(&repo_path)
1394 .output()
1395 .unwrap();
1396
1397 Command::new("git")
1398 .args(["commit", "-m", "Initial commit"])
1399 .current_dir(&repo_path)
1400 .output()
1401 .unwrap();
1402
1403 crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
1405 .unwrap();
1406
1407 (temp_dir, repo_path)
1408 }
1409
1410 #[test]
1411 fn test_create_stack_manager() {
1412 let (_temp_dir, repo_path) = create_test_repo();
1413 let manager = StackManager::new(&repo_path).unwrap();
1414
1415 assert_eq!(manager.stacks.len(), 0);
1416 assert!(manager.get_active_stack().is_none());
1417 }
1418
1419 #[test]
1420 fn test_create_and_manage_stack() {
1421 let (_temp_dir, repo_path) = create_test_repo();
1422 let mut manager = StackManager::new(&repo_path).unwrap();
1423
1424 let stack_id = manager
1426 .create_stack(
1427 "test-stack".to_string(),
1428 None, Some("Test stack description".to_string()),
1430 )
1431 .unwrap();
1432
1433 assert_eq!(manager.stacks.len(), 1);
1435 let stack = manager.get_stack(&stack_id).unwrap();
1436 assert_eq!(stack.name, "test-stack");
1437 assert!(!stack.base_branch.is_empty());
1439 assert!(stack.is_active);
1440
1441 let active = manager.get_active_stack().unwrap();
1443 assert_eq!(active.id, stack_id);
1444
1445 let found = manager.get_stack_by_name("test-stack").unwrap();
1447 assert_eq!(found.id, stack_id);
1448 }
1449
1450 #[test]
1451 fn test_stack_persistence() {
1452 let (_temp_dir, repo_path) = create_test_repo();
1453
1454 let stack_id = {
1455 let mut manager = StackManager::new(&repo_path).unwrap();
1456 manager
1457 .create_stack("persistent-stack".to_string(), None, None)
1458 .unwrap()
1459 };
1460
1461 let manager = StackManager::new(&repo_path).unwrap();
1463 assert_eq!(manager.stacks.len(), 1);
1464 let stack = manager.get_stack(&stack_id).unwrap();
1465 assert_eq!(stack.name, "persistent-stack");
1466 }
1467
1468 #[test]
1469 fn test_multiple_stacks() {
1470 let (_temp_dir, repo_path) = create_test_repo();
1471 let mut manager = StackManager::new(&repo_path).unwrap();
1472
1473 let stack1_id = manager
1474 .create_stack("stack-1".to_string(), None, None)
1475 .unwrap();
1476 let stack2_id = manager
1477 .create_stack("stack-2".to_string(), None, None)
1478 .unwrap();
1479
1480 assert_eq!(manager.stacks.len(), 2);
1481
1482 assert!(!manager.get_stack(&stack1_id).unwrap().is_active);
1484 assert!(manager.get_stack(&stack2_id).unwrap().is_active);
1485
1486 manager.set_active_stack(Some(stack2_id)).unwrap();
1488 assert!(!manager.get_stack(&stack1_id).unwrap().is_active);
1489 assert!(manager.get_stack(&stack2_id).unwrap().is_active);
1490 }
1491
1492 #[test]
1493 fn test_delete_stack() {
1494 let (_temp_dir, repo_path) = create_test_repo();
1495 let mut manager = StackManager::new(&repo_path).unwrap();
1496
1497 let stack_id = manager
1498 .create_stack("to-delete".to_string(), None, None)
1499 .unwrap();
1500 assert_eq!(manager.stacks.len(), 1);
1501
1502 let deleted = manager.delete_stack(&stack_id).unwrap();
1503 assert_eq!(deleted.name, "to-delete");
1504 assert_eq!(manager.stacks.len(), 0);
1505 assert!(manager.get_active_stack().is_none());
1506 }
1507
1508 #[test]
1509 fn test_validation() {
1510 let (_temp_dir, repo_path) = create_test_repo();
1511 let mut manager = StackManager::new(&repo_path).unwrap();
1512
1513 manager
1514 .create_stack("valid-stack".to_string(), None, None)
1515 .unwrap();
1516
1517 assert!(manager.validate_all().is_ok());
1519 }
1520
1521 #[test]
1522 fn test_duplicate_commit_message_detection() {
1523 let (_temp_dir, repo_path) = create_test_repo();
1524 let mut manager = StackManager::new(&repo_path).unwrap();
1525
1526 manager
1528 .create_stack("test-stack".to_string(), None, None)
1529 .unwrap();
1530
1531 std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1533 Command::new("git")
1534 .args(["add", "file1.txt"])
1535 .current_dir(&repo_path)
1536 .output()
1537 .unwrap();
1538
1539 Command::new("git")
1540 .args(["commit", "-m", "Add authentication feature"])
1541 .current_dir(&repo_path)
1542 .output()
1543 .unwrap();
1544
1545 let commit1_hash = Command::new("git")
1546 .args(["rev-parse", "HEAD"])
1547 .current_dir(&repo_path)
1548 .output()
1549 .unwrap();
1550 let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1551 .trim()
1552 .to_string();
1553
1554 let entry1_id = manager
1556 .push_to_stack(
1557 "feature/auth".to_string(),
1558 commit1_hash,
1559 "Add authentication feature".to_string(),
1560 "main".to_string(),
1561 )
1562 .unwrap();
1563
1564 assert!(manager
1566 .get_active_stack()
1567 .unwrap()
1568 .get_entry(&entry1_id)
1569 .is_some());
1570
1571 std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1573 Command::new("git")
1574 .args(["add", "file2.txt"])
1575 .current_dir(&repo_path)
1576 .output()
1577 .unwrap();
1578
1579 Command::new("git")
1580 .args(["commit", "-m", "Different commit message"])
1581 .current_dir(&repo_path)
1582 .output()
1583 .unwrap();
1584
1585 let commit2_hash = Command::new("git")
1586 .args(["rev-parse", "HEAD"])
1587 .current_dir(&repo_path)
1588 .output()
1589 .unwrap();
1590 let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1591 .trim()
1592 .to_string();
1593
1594 let result = manager.push_to_stack(
1596 "feature/auth2".to_string(),
1597 commit2_hash.clone(),
1598 "Add authentication feature".to_string(), "main".to_string(),
1600 );
1601
1602 assert!(result.is_err());
1604 let error = result.unwrap_err();
1605 assert!(matches!(error, CascadeError::Validation(_)));
1606
1607 let error_msg = error.to_string();
1609 assert!(error_msg.contains("Duplicate commit message"));
1610 assert!(error_msg.contains("Add authentication feature"));
1611 assert!(error_msg.contains("š” Consider using a more specific message"));
1612
1613 let entry2_id = manager
1615 .push_to_stack(
1616 "feature/auth2".to_string(),
1617 commit2_hash,
1618 "Add authentication validation".to_string(), "main".to_string(),
1620 )
1621 .unwrap();
1622
1623 let stack = manager.get_active_stack().unwrap();
1625 assert_eq!(stack.entries.len(), 2);
1626 assert!(stack.get_entry(&entry1_id).is_some());
1627 assert!(stack.get_entry(&entry2_id).is_some());
1628 }
1629
1630 #[test]
1631 fn test_duplicate_message_with_different_case() {
1632 let (_temp_dir, repo_path) = create_test_repo();
1633 let mut manager = StackManager::new(&repo_path).unwrap();
1634
1635 manager
1636 .create_stack("test-stack".to_string(), None, None)
1637 .unwrap();
1638
1639 std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1641 Command::new("git")
1642 .args(["add", "file1.txt"])
1643 .current_dir(&repo_path)
1644 .output()
1645 .unwrap();
1646
1647 Command::new("git")
1648 .args(["commit", "-m", "fix bug"])
1649 .current_dir(&repo_path)
1650 .output()
1651 .unwrap();
1652
1653 let commit1_hash = Command::new("git")
1654 .args(["rev-parse", "HEAD"])
1655 .current_dir(&repo_path)
1656 .output()
1657 .unwrap();
1658 let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1659 .trim()
1660 .to_string();
1661
1662 manager
1663 .push_to_stack(
1664 "feature/fix1".to_string(),
1665 commit1_hash,
1666 "fix bug".to_string(),
1667 "main".to_string(),
1668 )
1669 .unwrap();
1670
1671 std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1673 Command::new("git")
1674 .args(["add", "file2.txt"])
1675 .current_dir(&repo_path)
1676 .output()
1677 .unwrap();
1678
1679 Command::new("git")
1680 .args(["commit", "-m", "Fix Bug"])
1681 .current_dir(&repo_path)
1682 .output()
1683 .unwrap();
1684
1685 let commit2_hash = Command::new("git")
1686 .args(["rev-parse", "HEAD"])
1687 .current_dir(&repo_path)
1688 .output()
1689 .unwrap();
1690 let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1691 .trim()
1692 .to_string();
1693
1694 let result = manager.push_to_stack(
1696 "feature/fix2".to_string(),
1697 commit2_hash,
1698 "Fix Bug".to_string(), "main".to_string(),
1700 );
1701
1702 assert!(result.is_ok());
1704 }
1705
1706 #[test]
1707 fn test_duplicate_message_across_different_stacks() {
1708 let (_temp_dir, repo_path) = create_test_repo();
1709 let mut manager = StackManager::new(&repo_path).unwrap();
1710
1711 let stack1_id = manager
1713 .create_stack("stack1".to_string(), None, None)
1714 .unwrap();
1715
1716 std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1717 Command::new("git")
1718 .args(["add", "file1.txt"])
1719 .current_dir(&repo_path)
1720 .output()
1721 .unwrap();
1722
1723 Command::new("git")
1724 .args(["commit", "-m", "shared message"])
1725 .current_dir(&repo_path)
1726 .output()
1727 .unwrap();
1728
1729 let commit1_hash = Command::new("git")
1730 .args(["rev-parse", "HEAD"])
1731 .current_dir(&repo_path)
1732 .output()
1733 .unwrap();
1734 let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1735 .trim()
1736 .to_string();
1737
1738 manager
1739 .push_to_stack(
1740 "feature/shared1".to_string(),
1741 commit1_hash,
1742 "shared message".to_string(),
1743 "main".to_string(),
1744 )
1745 .unwrap();
1746
1747 let stack2_id = manager
1749 .create_stack("stack2".to_string(), None, None)
1750 .unwrap();
1751
1752 manager.set_active_stack(Some(stack2_id)).unwrap();
1754
1755 std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1757 Command::new("git")
1758 .args(["add", "file2.txt"])
1759 .current_dir(&repo_path)
1760 .output()
1761 .unwrap();
1762
1763 Command::new("git")
1764 .args(["commit", "-m", "shared message"])
1765 .current_dir(&repo_path)
1766 .output()
1767 .unwrap();
1768
1769 let commit2_hash = Command::new("git")
1770 .args(["rev-parse", "HEAD"])
1771 .current_dir(&repo_path)
1772 .output()
1773 .unwrap();
1774 let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1775 .trim()
1776 .to_string();
1777
1778 let result = manager.push_to_stack(
1780 "feature/shared2".to_string(),
1781 commit2_hash,
1782 "shared message".to_string(), "main".to_string(),
1784 );
1785
1786 assert!(result.is_ok());
1788
1789 let stack1 = manager.get_stack(&stack1_id).unwrap();
1791 let stack2 = manager.get_stack(&stack2_id).unwrap();
1792
1793 assert_eq!(stack1.entries.len(), 1);
1794 assert_eq!(stack2.entries.len(), 1);
1795 assert_eq!(stack1.entries[0].message, "shared message");
1796 assert_eq!(stack2.entries[0].message, "shared message");
1797 }
1798
1799 #[test]
1800 fn test_duplicate_after_pop() {
1801 let (_temp_dir, repo_path) = create_test_repo();
1802 let mut manager = StackManager::new(&repo_path).unwrap();
1803
1804 manager
1805 .create_stack("test-stack".to_string(), None, None)
1806 .unwrap();
1807
1808 std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1810 Command::new("git")
1811 .args(["add", "file1.txt"])
1812 .current_dir(&repo_path)
1813 .output()
1814 .unwrap();
1815
1816 Command::new("git")
1817 .args(["commit", "-m", "temporary message"])
1818 .current_dir(&repo_path)
1819 .output()
1820 .unwrap();
1821
1822 let commit1_hash = Command::new("git")
1823 .args(["rev-parse", "HEAD"])
1824 .current_dir(&repo_path)
1825 .output()
1826 .unwrap();
1827 let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1828 .trim()
1829 .to_string();
1830
1831 manager
1832 .push_to_stack(
1833 "feature/temp".to_string(),
1834 commit1_hash,
1835 "temporary message".to_string(),
1836 "main".to_string(),
1837 )
1838 .unwrap();
1839
1840 let popped = manager.pop_from_stack().unwrap();
1842 assert_eq!(popped.message, "temporary message");
1843
1844 std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1846 Command::new("git")
1847 .args(["add", "file2.txt"])
1848 .current_dir(&repo_path)
1849 .output()
1850 .unwrap();
1851
1852 Command::new("git")
1853 .args(["commit", "-m", "temporary message"])
1854 .current_dir(&repo_path)
1855 .output()
1856 .unwrap();
1857
1858 let commit2_hash = Command::new("git")
1859 .args(["rev-parse", "HEAD"])
1860 .current_dir(&repo_path)
1861 .output()
1862 .unwrap();
1863 let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1864 .trim()
1865 .to_string();
1866
1867 let result = manager.push_to_stack(
1869 "feature/temp2".to_string(),
1870 commit2_hash,
1871 "temporary message".to_string(),
1872 "main".to_string(),
1873 );
1874
1875 assert!(result.is_ok());
1876 }
1877}