1use super::metadata::RepositoryMetadata;
2use super::{CommitMetadata, Stack, StackEntry, StackMetadata, StackStatus};
3use crate::config::{get_repo_config_dir, Settings};
4use crate::errors::{CascadeError, Result};
5use crate::git::GitRepository;
6use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9use tracing::info;
10use uuid::Uuid;
11
12#[derive(Debug)]
14pub enum BranchModification {
15 Missing {
17 branch: String,
18 entry_id: Uuid,
19 expected_commit: String,
20 },
21 ExtraCommits {
23 branch: String,
24 entry_id: Uuid,
25 expected_commit: String,
26 actual_commit: String,
27 extra_commit_count: usize,
28 extra_commit_messages: Vec<String>,
29 },
30}
31
32pub struct StackManager {
34 repo: GitRepository,
36 repo_path: PathBuf,
38 config_dir: PathBuf,
40 stacks_file: PathBuf,
42 metadata_file: PathBuf,
44 stacks: HashMap<Uuid, Stack>,
46 metadata: RepositoryMetadata,
48}
49
50impl StackManager {
51 pub fn new(repo_path: &Path) -> Result<Self> {
53 let repo = GitRepository::open(repo_path)?;
54 let config_dir = get_repo_config_dir(repo_path)?;
55 let stacks_file = config_dir.join("stacks.json");
56 let metadata_file = config_dir.join("metadata.json");
57
58 let config_file = config_dir.join("config.json");
60 let settings = Settings::load_from_file(&config_file).unwrap_or_default();
61 let configured_default = &settings.git.default_branch;
62
63 let default_base = if repo.branch_exists(configured_default) {
67 configured_default.clone()
69 } else {
70 match repo.detect_main_branch() {
72 Ok(detected) => {
73 detected
75 }
76 Err(_) => {
77 configured_default.clone()
80 }
81 }
82 };
83
84 let mut manager = Self {
85 repo,
86 repo_path: repo_path.to_path_buf(),
87 config_dir,
88 stacks_file,
89 metadata_file,
90 stacks: HashMap::new(),
91 metadata: RepositoryMetadata::new(default_base),
92 };
93
94 manager.load_from_disk()?;
96
97 Ok(manager)
98 }
99
100 pub fn create_stack(
102 &mut self,
103 name: String,
104 base_branch: Option<String>,
105 description: Option<String>,
106 ) -> Result<Uuid> {
107 if self.metadata.find_stack_by_name(&name).is_some() {
109 return Err(CascadeError::config(format!(
110 "Stack '{name}' already exists"
111 )));
112 }
113
114 let base_branch = base_branch.unwrap_or_else(|| self.metadata.default_base_branch.clone());
116
117 if !self.repo.branch_exists_or_fetch(&base_branch)? {
119 return Err(CascadeError::branch(format!(
120 "Base branch '{base_branch}' does not exist locally or remotely"
121 )));
122 }
123
124 let current_branch = self.repo.get_current_branch().ok();
126
127 let mut stack = Stack::new(name.clone(), base_branch.clone(), description.clone());
129
130 if let Some(ref branch) = current_branch {
132 if branch != &base_branch {
133 stack.working_branch = Some(branch.clone());
134 }
135 }
136
137 let stack_id = stack.id;
138
139 let stack_metadata = StackMetadata::new(stack_id, name, base_branch, description);
141
142 self.stacks.insert(stack_id, stack);
144 self.metadata.add_stack(stack_metadata);
145
146 self.set_active_stack(Some(stack_id))?;
148
149 Ok(stack_id)
150 }
151
152 pub fn get_stack(&self, stack_id: &Uuid) -> Option<&Stack> {
154 self.stacks.get(stack_id)
155 }
156
157 pub fn get_stack_mut(&mut self, stack_id: &Uuid) -> Option<&mut Stack> {
159 self.stacks.get_mut(stack_id)
160 }
161
162 pub fn get_stack_by_name(&self, name: &str) -> Option<&Stack> {
164 if let Some(metadata) = self.metadata.find_stack_by_name(name) {
165 self.stacks.get(&metadata.stack_id)
166 } else {
167 None
168 }
169 }
170
171 pub fn get_stack_by_name_mut(&mut self, name: &str) -> Option<&mut Stack> {
173 if let Some(metadata) = self.metadata.find_stack_by_name(name) {
174 self.stacks.get_mut(&metadata.stack_id)
175 } else {
176 None
177 }
178 }
179
180 pub fn update_stack_working_branch(&mut self, name: &str, branch: String) -> Result<()> {
182 if let Some(stack) = self.get_stack_by_name_mut(name) {
183 stack.working_branch = Some(branch);
184 self.save_to_disk()?;
185 Ok(())
186 } else {
187 Err(CascadeError::config(format!("Stack '{name}' not found")))
188 }
189 }
190
191 pub fn get_active_stack(&self) -> Option<&Stack> {
193 self.metadata
194 .active_stack_id
195 .and_then(|id| self.stacks.get(&id))
196 }
197
198 pub fn get_active_stack_mut(&mut self) -> Option<&mut Stack> {
200 if let Some(id) = self.metadata.active_stack_id {
201 self.stacks.get_mut(&id)
202 } else {
203 None
204 }
205 }
206
207 pub fn set_active_stack(&mut self, stack_id: Option<Uuid>) -> Result<()> {
209 if let Some(id) = stack_id {
211 if !self.stacks.contains_key(&id) {
212 return Err(CascadeError::config(format!(
213 "Stack with ID {id} not found"
214 )));
215 }
216 }
217
218 for stack in self.stacks.values_mut() {
220 stack.set_active(Some(stack.id) == stack_id);
221 }
222
223 if let Some(id) = stack_id {
225 let current_branch = self.repo.get_current_branch().ok();
226 if let Some(stack_meta) = self.metadata.get_stack_mut(&id) {
227 stack_meta.set_current_branch(current_branch);
228 }
229 }
230
231 self.metadata.set_active_stack(stack_id);
232 self.save_to_disk()?;
233
234 Ok(())
235 }
236
237 pub fn set_active_stack_by_name(&mut self, name: &str) -> Result<()> {
239 if let Some(metadata) = self.metadata.find_stack_by_name(name) {
240 self.set_active_stack(Some(metadata.stack_id))
241 } else {
242 Err(CascadeError::config(format!("Stack '{name}' not found")))
243 }
244 }
245
246 pub fn delete_stack(&mut self, stack_id: &Uuid) -> Result<Stack> {
248 let stack = self
249 .stacks
250 .remove(stack_id)
251 .ok_or_else(|| CascadeError::config(format!("Stack with ID {stack_id} not found")))?;
252
253 self.metadata.remove_stack(stack_id);
255
256 let stack_commits: Vec<String> = self
258 .metadata
259 .commits
260 .values()
261 .filter(|commit| &commit.stack_id == stack_id)
262 .map(|commit| commit.hash.clone())
263 .collect();
264
265 for commit_hash in stack_commits {
266 self.metadata.remove_commit(&commit_hash);
267 }
268
269 if self.metadata.active_stack_id == Some(*stack_id) {
271 let new_active = self.metadata.stacks.keys().next().copied();
272 self.set_active_stack(new_active)?;
273 }
274
275 self.save_to_disk()?;
276
277 Ok(stack)
278 }
279
280 pub fn push_to_stack(
282 &mut self,
283 branch: String,
284 commit_hash: String,
285 message: String,
286 source_branch: String,
287 ) -> Result<Uuid> {
288 let stack_id = self
289 .metadata
290 .active_stack_id
291 .ok_or_else(|| CascadeError::config("No active stack"))?;
292
293 let stack = self
294 .stacks
295 .get_mut(&stack_id)
296 .ok_or_else(|| CascadeError::config("Active stack not found"))?;
297
298 if !stack.entries.is_empty() {
300 if let Err(integrity_error) = stack.validate_git_integrity(&self.repo) {
301 return Err(CascadeError::validation(format!(
302 "Cannot push to corrupted stack '{}':\n{}\n\n\
303 š” Fix the stack integrity issues first using 'ca stack validate {}' for details.",
304 stack.name, integrity_error, stack.name
305 )));
306 }
307 }
308
309 if !self.repo.commit_exists(&commit_hash)? {
311 return Err(CascadeError::branch(format!(
312 "Commit {commit_hash} does not exist"
313 )));
314 }
315
316 if let Some(duplicate_entry) = stack.entries.iter().find(|entry| entry.message == message) {
318 return Err(CascadeError::validation(format!(
319 "Duplicate commit message in stack: \"{message}\"\n\n\
320 This message already exists in entry {} (commit: {})\n\n\
321 š” Consider using a more specific message:\n\
322 ⢠Add context: \"{message} - add validation\"\n\
323 ⢠Be more specific: \"Fix user authentication timeout bug\"\n\
324 ⢠Or amend the previous commit: git commit --amend",
325 duplicate_entry.id,
326 &duplicate_entry.commit_hash[..8]
327 )));
328 }
329
330 if stack.entries.is_empty() {
335 let current_branch = self.repo.get_current_branch()?;
336
337 if stack.working_branch.is_none() && current_branch != stack.base_branch {
339 stack.working_branch = Some(current_branch.clone());
340 tracing::info!(
341 "Set working branch for stack '{}' to '{}'",
342 stack.name,
343 current_branch
344 );
345 }
346
347 if current_branch != stack.base_branch && current_branch != "HEAD" {
348 let base_exists = self.repo.branch_exists(&stack.base_branch);
350 let current_is_feature = current_branch.starts_with("feature/")
351 || current_branch.starts_with("fix/")
352 || current_branch.starts_with("chore/")
353 || current_branch.contains("feature")
354 || current_branch.contains("fix");
355
356 if base_exists && current_is_feature {
357 tracing::info!(
358 "šÆ First commit detected: updating stack '{}' base branch from '{}' to '{}'",
359 stack.name, stack.base_branch, current_branch
360 );
361
362 println!("šÆ Smart Base Branch Update:");
363 println!(
364 " Stack '{}' was created with base '{}'",
365 stack.name, stack.base_branch
366 );
367 println!(" You're now working on feature branch '{current_branch}'");
368 println!(" Updating stack base branch to match your workflow");
369
370 stack.base_branch = current_branch.clone();
372
373 if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
375 stack_meta.base_branch = current_branch.clone();
376 stack_meta.set_current_branch(Some(current_branch.clone()));
377 }
378
379 println!(
380 " ā
Stack '{}' base branch updated to '{current_branch}'",
381 stack.name
382 );
383 }
384 }
385 }
386
387 if self.repo.branch_exists(&branch) {
390 } else {
392 self.repo
394 .create_branch(&branch, Some(&commit_hash))
395 .map_err(|e| {
396 CascadeError::branch(format!(
397 "Failed to create branch '{}' from commit {}: {}",
398 branch,
399 &commit_hash[..8],
400 e
401 ))
402 })?;
403
404 }
406
407 let entry_id = stack.push_entry(branch.clone(), commit_hash.clone(), message.clone());
409
410 let commit_metadata = CommitMetadata::new(
412 commit_hash.clone(),
413 message,
414 entry_id,
415 stack_id,
416 branch.clone(),
417 source_branch,
418 );
419
420 self.metadata.add_commit(commit_metadata);
422 if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
423 stack_meta.add_branch(branch);
424 stack_meta.add_commit(commit_hash);
425 }
426
427 self.save_to_disk()?;
428
429 Ok(entry_id)
430 }
431
432 pub fn pop_from_stack(&mut self) -> Result<StackEntry> {
434 let stack_id = self
435 .metadata
436 .active_stack_id
437 .ok_or_else(|| CascadeError::config("No active stack"))?;
438
439 let stack = self
440 .stacks
441 .get_mut(&stack_id)
442 .ok_or_else(|| CascadeError::config("Active stack not found"))?;
443
444 let entry = stack
445 .pop_entry()
446 .ok_or_else(|| CascadeError::config("Stack is empty"))?;
447
448 self.metadata.remove_commit(&entry.commit_hash);
450
451 if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
453 stack_meta.remove_commit(&entry.commit_hash);
454 }
456
457 self.save_to_disk()?;
458
459 Ok(entry)
460 }
461
462 pub fn submit_entry(
464 &mut self,
465 stack_id: &Uuid,
466 entry_id: &Uuid,
467 pull_request_id: String,
468 ) -> Result<()> {
469 let stack = self
470 .stacks
471 .get_mut(stack_id)
472 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
473
474 let entry_commit_hash = {
475 let entry = stack
476 .get_entry(entry_id)
477 .ok_or_else(|| CascadeError::config(format!("Entry {entry_id} not found")))?;
478 entry.commit_hash.clone()
479 };
480
481 if !stack.mark_entry_submitted(entry_id, pull_request_id.clone()) {
483 return Err(CascadeError::config(format!(
484 "Failed to mark entry {entry_id} as submitted"
485 )));
486 }
487
488 if let Some(commit_meta) = self.metadata.commits.get_mut(&entry_commit_hash) {
490 commit_meta.mark_submitted(pull_request_id);
491 }
492
493 if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
495 let submitted_count = stack.entries.iter().filter(|e| e.is_submitted).count();
496 stack_meta.update_stats(
497 stack.entries.len(),
498 submitted_count,
499 stack_meta.merged_commits,
500 );
501 }
502
503 self.save_to_disk()?;
504
505 Ok(())
506 }
507
508 pub fn repair_all_stacks(&mut self) -> Result<()> {
510 for stack in self.stacks.values_mut() {
511 stack.repair_data_consistency();
512 }
513 self.save_to_disk()?;
514 Ok(())
515 }
516
517 pub fn get_all_stacks(&self) -> Vec<&Stack> {
519 self.stacks.values().collect()
520 }
521
522 pub fn get_stack_metadata(&self, stack_id: &Uuid) -> Option<&StackMetadata> {
524 self.metadata.get_stack(stack_id)
525 }
526
527 pub fn get_repository_metadata(&self) -> &RepositoryMetadata {
529 &self.metadata
530 }
531
532 pub fn git_repo(&self) -> &GitRepository {
534 &self.repo
535 }
536
537 pub fn repo_path(&self) -> &Path {
539 &self.repo_path
540 }
541
542 pub fn is_in_edit_mode(&self) -> bool {
546 self.metadata
547 .edit_mode
548 .as_ref()
549 .map(|edit_state| edit_state.is_active)
550 .unwrap_or(false)
551 }
552
553 pub fn get_edit_mode_info(&self) -> Option<&super::metadata::EditModeState> {
555 self.metadata.edit_mode.as_ref()
556 }
557
558 pub fn enter_edit_mode(&mut self, stack_id: Uuid, entry_id: Uuid) -> Result<()> {
560 let commit_hash = {
562 let stack = self
563 .get_stack(&stack_id)
564 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
565
566 let entry = stack.get_entry(&entry_id).ok_or_else(|| {
567 CascadeError::config(format!("Entry {entry_id} not found in stack"))
568 })?;
569
570 entry.commit_hash.clone()
571 };
572
573 if self.is_in_edit_mode() {
575 self.exit_edit_mode()?;
576 }
577
578 let edit_state = super::metadata::EditModeState::new(stack_id, entry_id, commit_hash);
580
581 self.metadata.edit_mode = Some(edit_state);
582 self.save_to_disk()?;
583
584 info!(
585 "Entered edit mode for entry {} in stack {}",
586 entry_id, stack_id
587 );
588 Ok(())
589 }
590
591 pub fn exit_edit_mode(&mut self) -> Result<()> {
593 if !self.is_in_edit_mode() {
594 return Err(CascadeError::config("Not currently in edit mode"));
595 }
596
597 self.metadata.edit_mode = None;
599 self.save_to_disk()?;
600
601 info!("Exited edit mode");
602 Ok(())
603 }
604
605 pub fn sync_stack(&mut self, stack_id: &Uuid) -> Result<()> {
607 let stack = self
608 .stacks
609 .get_mut(stack_id)
610 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
611
612 if let Err(integrity_error) = stack.validate_git_integrity(&self.repo) {
614 stack.update_status(StackStatus::Corrupted);
615 return Err(CascadeError::branch(format!(
616 "Stack '{}' Git integrity check failed:\n{}",
617 stack.name, integrity_error
618 )));
619 }
620
621 let mut missing_commits = Vec::new();
623 for entry in &stack.entries {
624 if !self.repo.commit_exists(&entry.commit_hash)? {
625 missing_commits.push(entry.commit_hash.clone());
626 }
627 }
628
629 if !missing_commits.is_empty() {
630 stack.update_status(StackStatus::Corrupted);
631 return Err(CascadeError::branch(format!(
632 "Stack {} has missing commits: {}",
633 stack.name,
634 missing_commits.join(", ")
635 )));
636 }
637
638 if !self.repo.branch_exists_or_fetch(&stack.base_branch)? {
640 return Err(CascadeError::branch(format!(
641 "Base branch '{}' does not exist locally or remotely. Check the branch name or switch to a different base.",
642 stack.base_branch
643 )));
644 }
645
646 let _base_hash = self.repo.get_branch_head(&stack.base_branch)?;
647
648 let mut corrupted_entry = None;
650 for entry in &stack.entries {
651 if !self.repo.commit_exists(&entry.commit_hash)? {
652 corrupted_entry = Some((entry.commit_hash.clone(), entry.branch.clone()));
653 break;
654 }
655 }
656
657 if let Some((commit_hash, branch)) = corrupted_entry {
658 stack.update_status(StackStatus::Corrupted);
659 return Err(CascadeError::branch(format!(
660 "Commit {commit_hash} from stack entry '{branch}' no longer exists"
661 )));
662 }
663
664 let needs_sync = if let Some(first_entry) = stack.entries.first() {
666 match self
668 .repo
669 .get_commits_between(&stack.base_branch, &first_entry.commit_hash)
670 {
671 Ok(commits) => !commits.is_empty(), Err(_) => true, }
674 } else {
675 false };
677
678 if needs_sync {
680 stack.update_status(StackStatus::NeedsSync);
681 info!(
682 "Stack '{}' needs sync - new commits on base branch",
683 stack.name
684 );
685 } else {
686 stack.update_status(StackStatus::Clean);
687 info!("Stack '{}' is clean", stack.name);
688 }
689
690 if let Some(stack_meta) = self.metadata.get_stack_mut(stack_id) {
692 stack_meta.set_up_to_date(true);
693 }
694
695 self.save_to_disk()?;
696
697 Ok(())
698 }
699
700 pub fn list_stacks(&self) -> Vec<(Uuid, &str, &StackStatus, usize, Option<&str>)> {
702 self.stacks
703 .values()
704 .map(|stack| {
705 (
706 stack.id,
707 stack.name.as_str(),
708 &stack.status,
709 stack.entries.len(),
710 if stack.is_active {
711 Some("active")
712 } else {
713 None
714 },
715 )
716 })
717 .collect()
718 }
719
720 pub fn get_all_stacks_objects(&self) -> Result<Vec<Stack>> {
722 let mut stacks: Vec<Stack> = self.stacks.values().cloned().collect();
723 stacks.sort_by(|a, b| a.name.cmp(&b.name));
724 Ok(stacks)
725 }
726
727 pub fn validate_all(&self) -> Result<()> {
729 for stack in self.stacks.values() {
730 stack.validate().map_err(|e| {
732 CascadeError::config(format!("Stack '{}' validation failed: {}", stack.name, e))
733 })?;
734
735 stack.validate_git_integrity(&self.repo).map_err(|e| {
737 CascadeError::config(format!(
738 "Stack '{}' Git integrity validation failed: {}",
739 stack.name, e
740 ))
741 })?;
742 }
743 Ok(())
744 }
745
746 pub fn validate_stack(&self, stack_id: &Uuid) -> Result<()> {
748 let stack = self
749 .stacks
750 .get(stack_id)
751 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
752
753 stack.validate().map_err(|e| {
755 CascadeError::config(format!("Stack '{}' validation failed: {}", stack.name, e))
756 })?;
757
758 stack.validate_git_integrity(&self.repo).map_err(|e| {
760 CascadeError::config(format!(
761 "Stack '{}' Git integrity validation failed: {}",
762 stack.name, e
763 ))
764 })?;
765
766 Ok(())
767 }
768
769 fn save_to_disk(&self) -> Result<()> {
771 if !self.config_dir.exists() {
773 fs::create_dir_all(&self.config_dir).map_err(|e| {
774 CascadeError::config(format!("Failed to create config directory: {e}"))
775 })?;
776 }
777
778 crate::utils::atomic_file::write_json(&self.stacks_file, &self.stacks)?;
780
781 crate::utils::atomic_file::write_json(&self.metadata_file, &self.metadata)?;
783
784 Ok(())
785 }
786
787 fn load_from_disk(&mut self) -> Result<()> {
789 if self.stacks_file.exists() {
791 let stacks_content = fs::read_to_string(&self.stacks_file)
792 .map_err(|e| CascadeError::config(format!("Failed to read stacks file: {e}")))?;
793
794 self.stacks = serde_json::from_str(&stacks_content)
795 .map_err(|e| CascadeError::config(format!("Failed to parse stacks file: {e}")))?;
796 }
797
798 if self.metadata_file.exists() {
800 let metadata_content = fs::read_to_string(&self.metadata_file)
801 .map_err(|e| CascadeError::config(format!("Failed to read metadata file: {e}")))?;
802
803 self.metadata = serde_json::from_str(&metadata_content)
804 .map_err(|e| CascadeError::config(format!("Failed to parse metadata file: {e}")))?;
805 }
806
807 Ok(())
808 }
809
810 pub fn check_for_branch_change(&mut self) -> Result<bool> {
813 let (stack_id, stack_name, stored_branch) = {
815 if let Some(active_stack) = self.get_active_stack() {
816 let stack_id = active_stack.id;
817 let stack_name = active_stack.name.clone();
818 let stored_branch = if let Some(stack_meta) = self.metadata.get_stack(&stack_id) {
819 stack_meta.current_branch.clone()
820 } else {
821 None
822 };
823 (Some(stack_id), stack_name, stored_branch)
824 } else {
825 (None, String::new(), None)
826 }
827 };
828
829 let Some(stack_id) = stack_id else {
831 return Ok(true);
832 };
833
834 let current_branch = self.repo.get_current_branch().ok();
835
836 if stored_branch.as_ref() != current_branch.as_ref() {
838 println!("ā ļø Branch change detected!");
839 println!(
840 " Stack '{}' was active on: {}",
841 stack_name,
842 stored_branch.as_deref().unwrap_or("unknown")
843 );
844 println!(
845 " Current branch: {}",
846 current_branch.as_deref().unwrap_or("unknown")
847 );
848 println!();
849 println!("What would you like to do?");
850 println!(" 1. Keep stack '{stack_name}' active (continue with stack workflow)");
851 println!(" 2. Deactivate stack (use normal Git workflow)");
852 println!(" 3. Switch to a different stack");
853 println!(" 4. Cancel and stay on current workflow");
854 print!(" Choice (1-4): ");
855
856 use std::io::{self, Write};
857 io::stdout()
858 .flush()
859 .map_err(|e| CascadeError::config(format!("Failed to write to stdout: {e}")))?;
860
861 let mut input = String::new();
862 io::stdin()
863 .read_line(&mut input)
864 .map_err(|e| CascadeError::config(format!("Failed to read user input: {e}")))?;
865
866 match input.trim() {
867 "1" => {
868 if let Some(stack_meta) = self.metadata.get_stack_mut(&stack_id) {
870 stack_meta.set_current_branch(current_branch);
871 }
872 self.save_to_disk()?;
873 println!("ā
Continuing with stack '{stack_name}' on current branch");
874 return Ok(true);
875 }
876 "2" => {
877 self.set_active_stack(None)?;
879 println!("ā
Deactivated stack '{stack_name}' - using normal Git workflow");
880 return Ok(false);
881 }
882 "3" => {
883 let stacks = self.get_all_stacks();
885 if stacks.len() <= 1 {
886 println!("ā ļø No other stacks available. Deactivating current stack.");
887 self.set_active_stack(None)?;
888 return Ok(false);
889 }
890
891 println!("\nAvailable stacks:");
892 for (i, stack) in stacks.iter().enumerate() {
893 if stack.id != stack_id {
894 println!(" {}. {}", i + 1, stack.name);
895 }
896 }
897 print!(" Enter stack name: ");
898 io::stdout().flush().map_err(|e| {
899 CascadeError::config(format!("Failed to write to stdout: {e}"))
900 })?;
901
902 let mut stack_name_input = String::new();
903 io::stdin().read_line(&mut stack_name_input).map_err(|e| {
904 CascadeError::config(format!("Failed to read user input: {e}"))
905 })?;
906 let stack_name_input = stack_name_input.trim();
907
908 if let Err(e) = self.set_active_stack_by_name(stack_name_input) {
909 println!("ā ļø {e}");
910 println!(" Deactivating stack instead.");
911 self.set_active_stack(None)?;
912 return Ok(false);
913 } else {
914 println!("ā
Switched to stack '{stack_name_input}'");
915 return Ok(true);
916 }
917 }
918 _ => {
919 println!("Cancelled - no changes made");
920 return Ok(false);
921 }
922 }
923 }
924
925 Ok(true)
927 }
928
929 pub fn handle_branch_modifications(
932 &mut self,
933 stack_id: &Uuid,
934 auto_mode: Option<String>,
935 ) -> Result<()> {
936 let stack = self
937 .stacks
938 .get_mut(stack_id)
939 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
940
941 info!("Checking Git integrity for stack '{}'", stack.name);
942
943 let mut modifications = Vec::new();
945 for entry in &stack.entries {
946 if !self.repo.branch_exists(&entry.branch) {
947 modifications.push(BranchModification::Missing {
948 branch: entry.branch.clone(),
949 entry_id: entry.id,
950 expected_commit: entry.commit_hash.clone(),
951 });
952 } else if let Ok(branch_head) = self.repo.get_branch_head(&entry.branch) {
953 if branch_head != entry.commit_hash {
954 let extra_commits = self
956 .repo
957 .get_commits_between(&entry.commit_hash, &branch_head)?;
958 let mut extra_messages = Vec::new();
959 for commit in &extra_commits {
960 if let Some(message) = commit.message() {
961 let first_line =
962 message.lines().next().unwrap_or("(no message)").to_string();
963 extra_messages.push(format!(
964 "{}: {}",
965 &commit.id().to_string()[..8],
966 first_line
967 ));
968 }
969 }
970
971 modifications.push(BranchModification::ExtraCommits {
972 branch: entry.branch.clone(),
973 entry_id: entry.id,
974 expected_commit: entry.commit_hash.clone(),
975 actual_commit: branch_head,
976 extra_commit_count: extra_commits.len(),
977 extra_commit_messages: extra_messages,
978 });
979 }
980 }
981 }
982
983 if modifications.is_empty() {
984 println!("ā
Stack '{}' has no Git integrity issues", stack.name);
985 return Ok(());
986 }
987
988 println!(
990 "š Detected branch modifications in stack '{}':",
991 stack.name
992 );
993 for (i, modification) in modifications.iter().enumerate() {
994 match modification {
995 BranchModification::Missing { branch, .. } => {
996 println!(" {}. Branch '{}' is missing", i + 1, branch);
997 }
998 BranchModification::ExtraCommits {
999 branch,
1000 expected_commit,
1001 actual_commit,
1002 extra_commit_count,
1003 extra_commit_messages,
1004 ..
1005 } => {
1006 println!(
1007 " {}. Branch '{}' has {} extra commit(s)",
1008 i + 1,
1009 branch,
1010 extra_commit_count
1011 );
1012 println!(
1013 " Expected: {} | Actual: {}",
1014 &expected_commit[..8],
1015 &actual_commit[..8]
1016 );
1017
1018 for (j, message) in extra_commit_messages.iter().enumerate() {
1020 if j < 3 {
1021 println!(" + {message}");
1022 } else if j == 3 {
1023 println!(" + ... and {} more", extra_commit_count - 3);
1024 break;
1025 }
1026 }
1027 }
1028 }
1029 }
1030 println!();
1031
1032 if let Some(mode) = auto_mode {
1034 return self.apply_auto_fix(stack_id, &modifications, &mode);
1035 }
1036
1037 for modification in modifications {
1039 self.handle_single_modification(stack_id, &modification)?;
1040 }
1041
1042 self.save_to_disk()?;
1043 println!("š All branch modifications handled successfully!");
1044 Ok(())
1045 }
1046
1047 fn handle_single_modification(
1049 &mut self,
1050 stack_id: &Uuid,
1051 modification: &BranchModification,
1052 ) -> Result<()> {
1053 match modification {
1054 BranchModification::Missing {
1055 branch,
1056 expected_commit,
1057 ..
1058 } => {
1059 println!("š§ Missing branch '{branch}'");
1060 println!(
1061 " This will create the branch at commit {}",
1062 &expected_commit[..8]
1063 );
1064
1065 self.repo.create_branch(branch, Some(expected_commit))?;
1066 println!(" ā
Created branch '{branch}'");
1067 }
1068
1069 BranchModification::ExtraCommits {
1070 branch,
1071 entry_id,
1072 expected_commit,
1073 extra_commit_count,
1074 ..
1075 } => {
1076 println!(
1077 "š¤ Branch '{branch}' has {extra_commit_count} extra commit(s). What would you like to do?"
1078 );
1079 println!(" 1. š Incorporate - Update stack entry to include extra commits");
1080 println!(" 2. ā Split - Create new stack entry for extra commits");
1081 println!(" 3. šļø Reset - Remove extra commits (DESTRUCTIVE)");
1082 println!(" 4. āļø Skip - Leave as-is for now");
1083 print!(" Choice (1-4): ");
1084
1085 use std::io::{self, Write};
1086 io::stdout().flush().unwrap();
1087
1088 let mut input = String::new();
1089 io::stdin().read_line(&mut input).unwrap();
1090
1091 match input.trim() {
1092 "1" | "incorporate" | "inc" => {
1093 self.incorporate_extra_commits(stack_id, *entry_id, branch)?;
1094 }
1095 "2" | "split" | "new" => {
1096 self.split_extra_commits(stack_id, *entry_id, branch)?;
1097 }
1098 "3" | "reset" | "remove" => {
1099 self.reset_branch_destructive(branch, expected_commit)?;
1100 }
1101 "4" | "skip" | "ignore" => {
1102 println!(" āļø Skipping '{branch}' (integrity issue remains)");
1103 }
1104 _ => {
1105 println!(" ā Invalid choice. Skipping '{branch}'");
1106 }
1107 }
1108 }
1109 }
1110
1111 Ok(())
1112 }
1113
1114 fn apply_auto_fix(
1116 &mut self,
1117 stack_id: &Uuid,
1118 modifications: &[BranchModification],
1119 mode: &str,
1120 ) -> Result<()> {
1121 println!("š¤ Applying automatic fix mode: {mode}");
1122
1123 for modification in modifications {
1124 match (modification, mode) {
1125 (
1126 BranchModification::Missing {
1127 branch,
1128 expected_commit,
1129 ..
1130 },
1131 _,
1132 ) => {
1133 self.repo.create_branch(branch, Some(expected_commit))?;
1134 println!(" ā
Created missing branch '{branch}'");
1135 }
1136
1137 (
1138 BranchModification::ExtraCommits {
1139 branch, entry_id, ..
1140 },
1141 "incorporate",
1142 ) => {
1143 self.incorporate_extra_commits(stack_id, *entry_id, branch)?;
1144 }
1145
1146 (
1147 BranchModification::ExtraCommits {
1148 branch, entry_id, ..
1149 },
1150 "split",
1151 ) => {
1152 self.split_extra_commits(stack_id, *entry_id, branch)?;
1153 }
1154
1155 (
1156 BranchModification::ExtraCommits {
1157 branch,
1158 expected_commit,
1159 ..
1160 },
1161 "reset",
1162 ) => {
1163 self.reset_branch_destructive(branch, expected_commit)?;
1164 }
1165
1166 _ => {
1167 return Err(CascadeError::config(format!(
1168 "Unknown auto-fix mode '{mode}'. Use: incorporate, split, reset"
1169 )));
1170 }
1171 }
1172 }
1173
1174 self.save_to_disk()?;
1175 println!("š Auto-fix completed for mode: {mode}");
1176 Ok(())
1177 }
1178
1179 fn incorporate_extra_commits(
1181 &mut self,
1182 stack_id: &Uuid,
1183 entry_id: Uuid,
1184 branch: &str,
1185 ) -> Result<()> {
1186 let stack = self.stacks.get_mut(stack_id).unwrap();
1187
1188 if let Some(entry) = stack.entries.iter_mut().find(|e| e.id == entry_id) {
1189 let new_head = self.repo.get_branch_head(branch)?;
1190 let old_commit = entry.commit_hash[..8].to_string(); let extra_commits = self
1194 .repo
1195 .get_commits_between(&entry.commit_hash, &new_head)?;
1196
1197 entry.commit_hash = new_head.clone();
1199
1200 let mut extra_messages = Vec::new();
1202 for commit in &extra_commits {
1203 if let Some(message) = commit.message() {
1204 let first_line = message.lines().next().unwrap_or("").to_string();
1205 extra_messages.push(first_line);
1206 }
1207 }
1208
1209 if !extra_messages.is_empty() {
1210 entry.message = format!(
1211 "{}\n\nIncorporated commits:\n⢠{}",
1212 entry.message,
1213 extra_messages.join("\n⢠")
1214 );
1215 }
1216
1217 println!(
1218 " ā
Incorporated {} commit(s) into entry '{}'",
1219 extra_commits.len(),
1220 entry.short_hash()
1221 );
1222 println!(" Updated: {} -> {}", old_commit, &new_head[..8]);
1223 }
1224
1225 Ok(())
1226 }
1227
1228 fn split_extra_commits(&mut self, stack_id: &Uuid, entry_id: Uuid, branch: &str) -> Result<()> {
1230 let stack = self.stacks.get_mut(stack_id).unwrap();
1231 let new_head = self.repo.get_branch_head(branch)?;
1232
1233 let entry_position = stack
1235 .entries
1236 .iter()
1237 .position(|e| e.id == entry_id)
1238 .ok_or_else(|| CascadeError::config("Entry not found in stack"))?;
1239
1240 let base_name = branch.trim_end_matches(|c: char| c.is_ascii_digit() || c == '-');
1242 let new_branch = format!("{base_name}-continued");
1243
1244 self.repo.create_branch(&new_branch, Some(&new_head))?;
1246
1247 let original_entry = &stack.entries[entry_position];
1249 let original_commit_hash = original_entry.commit_hash.clone(); let extra_commits = self
1251 .repo
1252 .get_commits_between(&original_commit_hash, &new_head)?;
1253
1254 let mut extra_messages = Vec::new();
1256 for commit in &extra_commits {
1257 if let Some(message) = commit.message() {
1258 let first_line = message.lines().next().unwrap_or("").to_string();
1259 extra_messages.push(first_line);
1260 }
1261 }
1262
1263 let new_message = if extra_messages.len() == 1 {
1264 extra_messages[0].clone()
1265 } else {
1266 format!("Combined changes:\n⢠{}", extra_messages.join("\n⢠"))
1267 };
1268
1269 let now = chrono::Utc::now();
1271 let new_entry = crate::stack::StackEntry {
1272 id: uuid::Uuid::new_v4(),
1273 branch: new_branch.clone(),
1274 commit_hash: new_head,
1275 message: new_message,
1276 parent_id: Some(entry_id), children: Vec::new(),
1278 created_at: now,
1279 updated_at: now,
1280 is_submitted: false,
1281 pull_request_id: None,
1282 is_synced: false,
1283 };
1284
1285 stack.entries.insert(entry_position + 1, new_entry);
1287
1288 self.repo
1290 .reset_branch_to_commit(branch, &original_commit_hash)?;
1291
1292 println!(
1293 " ā
Split {} commit(s) into new entry '{}'",
1294 extra_commits.len(),
1295 new_branch
1296 );
1297 println!(" Original branch '{branch}' reset to expected commit");
1298
1299 Ok(())
1300 }
1301
1302 fn reset_branch_destructive(&self, branch: &str, expected_commit: &str) -> Result<()> {
1304 self.repo.reset_branch_to_commit(branch, expected_commit)?;
1305 println!(
1306 " ā ļø Reset branch '{}' to {} (extra commits lost)",
1307 branch,
1308 &expected_commit[..8]
1309 );
1310 Ok(())
1311 }
1312}
1313
1314#[cfg(test)]
1315mod tests {
1316 use super::*;
1317 use std::process::Command;
1318 use tempfile::TempDir;
1319
1320 fn create_test_repo() -> (TempDir, PathBuf) {
1321 let temp_dir = TempDir::new().unwrap();
1322 let repo_path = temp_dir.path().to_path_buf();
1323
1324 Command::new("git")
1326 .args(["init"])
1327 .current_dir(&repo_path)
1328 .output()
1329 .unwrap();
1330
1331 Command::new("git")
1333 .args(["config", "user.name", "Test User"])
1334 .current_dir(&repo_path)
1335 .output()
1336 .unwrap();
1337
1338 Command::new("git")
1339 .args(["config", "user.email", "test@example.com"])
1340 .current_dir(&repo_path)
1341 .output()
1342 .unwrap();
1343
1344 std::fs::write(repo_path.join("README.md"), "# Test Repo").unwrap();
1346 Command::new("git")
1347 .args(["add", "."])
1348 .current_dir(&repo_path)
1349 .output()
1350 .unwrap();
1351
1352 Command::new("git")
1353 .args(["commit", "-m", "Initial commit"])
1354 .current_dir(&repo_path)
1355 .output()
1356 .unwrap();
1357
1358 crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
1360 .unwrap();
1361
1362 (temp_dir, repo_path)
1363 }
1364
1365 #[test]
1366 fn test_create_stack_manager() {
1367 let (_temp_dir, repo_path) = create_test_repo();
1368 let manager = StackManager::new(&repo_path).unwrap();
1369
1370 assert_eq!(manager.stacks.len(), 0);
1371 assert!(manager.get_active_stack().is_none());
1372 }
1373
1374 #[test]
1375 fn test_create_and_manage_stack() {
1376 let (_temp_dir, repo_path) = create_test_repo();
1377 let mut manager = StackManager::new(&repo_path).unwrap();
1378
1379 let stack_id = manager
1381 .create_stack(
1382 "test-stack".to_string(),
1383 None, Some("Test stack description".to_string()),
1385 )
1386 .unwrap();
1387
1388 assert_eq!(manager.stacks.len(), 1);
1390 let stack = manager.get_stack(&stack_id).unwrap();
1391 assert_eq!(stack.name, "test-stack");
1392 assert!(!stack.base_branch.is_empty());
1394 assert!(stack.is_active);
1395
1396 let active = manager.get_active_stack().unwrap();
1398 assert_eq!(active.id, stack_id);
1399
1400 let found = manager.get_stack_by_name("test-stack").unwrap();
1402 assert_eq!(found.id, stack_id);
1403 }
1404
1405 #[test]
1406 fn test_stack_persistence() {
1407 let (_temp_dir, repo_path) = create_test_repo();
1408
1409 let stack_id = {
1410 let mut manager = StackManager::new(&repo_path).unwrap();
1411 manager
1412 .create_stack("persistent-stack".to_string(), None, None)
1413 .unwrap()
1414 };
1415
1416 let manager = StackManager::new(&repo_path).unwrap();
1418 assert_eq!(manager.stacks.len(), 1);
1419 let stack = manager.get_stack(&stack_id).unwrap();
1420 assert_eq!(stack.name, "persistent-stack");
1421 }
1422
1423 #[test]
1424 fn test_multiple_stacks() {
1425 let (_temp_dir, repo_path) = create_test_repo();
1426 let mut manager = StackManager::new(&repo_path).unwrap();
1427
1428 let stack1_id = manager
1429 .create_stack("stack-1".to_string(), None, None)
1430 .unwrap();
1431 let stack2_id = manager
1432 .create_stack("stack-2".to_string(), None, None)
1433 .unwrap();
1434
1435 assert_eq!(manager.stacks.len(), 2);
1436
1437 assert!(!manager.get_stack(&stack1_id).unwrap().is_active);
1439 assert!(manager.get_stack(&stack2_id).unwrap().is_active);
1440
1441 manager.set_active_stack(Some(stack2_id)).unwrap();
1443 assert!(!manager.get_stack(&stack1_id).unwrap().is_active);
1444 assert!(manager.get_stack(&stack2_id).unwrap().is_active);
1445 }
1446
1447 #[test]
1448 fn test_delete_stack() {
1449 let (_temp_dir, repo_path) = create_test_repo();
1450 let mut manager = StackManager::new(&repo_path).unwrap();
1451
1452 let stack_id = manager
1453 .create_stack("to-delete".to_string(), None, None)
1454 .unwrap();
1455 assert_eq!(manager.stacks.len(), 1);
1456
1457 let deleted = manager.delete_stack(&stack_id).unwrap();
1458 assert_eq!(deleted.name, "to-delete");
1459 assert_eq!(manager.stacks.len(), 0);
1460 assert!(manager.get_active_stack().is_none());
1461 }
1462
1463 #[test]
1464 fn test_validation() {
1465 let (_temp_dir, repo_path) = create_test_repo();
1466 let mut manager = StackManager::new(&repo_path).unwrap();
1467
1468 manager
1469 .create_stack("valid-stack".to_string(), None, None)
1470 .unwrap();
1471
1472 assert!(manager.validate_all().is_ok());
1474 }
1475
1476 #[test]
1477 fn test_duplicate_commit_message_detection() {
1478 let (_temp_dir, repo_path) = create_test_repo();
1479 let mut manager = StackManager::new(&repo_path).unwrap();
1480
1481 manager
1483 .create_stack("test-stack".to_string(), None, None)
1484 .unwrap();
1485
1486 std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1488 Command::new("git")
1489 .args(["add", "file1.txt"])
1490 .current_dir(&repo_path)
1491 .output()
1492 .unwrap();
1493
1494 Command::new("git")
1495 .args(["commit", "-m", "Add authentication feature"])
1496 .current_dir(&repo_path)
1497 .output()
1498 .unwrap();
1499
1500 let commit1_hash = Command::new("git")
1501 .args(["rev-parse", "HEAD"])
1502 .current_dir(&repo_path)
1503 .output()
1504 .unwrap();
1505 let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1506 .trim()
1507 .to_string();
1508
1509 let entry1_id = manager
1511 .push_to_stack(
1512 "feature/auth".to_string(),
1513 commit1_hash,
1514 "Add authentication feature".to_string(),
1515 "main".to_string(),
1516 )
1517 .unwrap();
1518
1519 assert!(manager
1521 .get_active_stack()
1522 .unwrap()
1523 .get_entry(&entry1_id)
1524 .is_some());
1525
1526 std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1528 Command::new("git")
1529 .args(["add", "file2.txt"])
1530 .current_dir(&repo_path)
1531 .output()
1532 .unwrap();
1533
1534 Command::new("git")
1535 .args(["commit", "-m", "Different commit message"])
1536 .current_dir(&repo_path)
1537 .output()
1538 .unwrap();
1539
1540 let commit2_hash = Command::new("git")
1541 .args(["rev-parse", "HEAD"])
1542 .current_dir(&repo_path)
1543 .output()
1544 .unwrap();
1545 let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1546 .trim()
1547 .to_string();
1548
1549 let result = manager.push_to_stack(
1551 "feature/auth2".to_string(),
1552 commit2_hash.clone(),
1553 "Add authentication feature".to_string(), "main".to_string(),
1555 );
1556
1557 assert!(result.is_err());
1559 let error = result.unwrap_err();
1560 assert!(matches!(error, CascadeError::Validation(_)));
1561
1562 let error_msg = error.to_string();
1564 assert!(error_msg.contains("Duplicate commit message"));
1565 assert!(error_msg.contains("Add authentication feature"));
1566 assert!(error_msg.contains("š” Consider using a more specific message"));
1567
1568 let entry2_id = manager
1570 .push_to_stack(
1571 "feature/auth2".to_string(),
1572 commit2_hash,
1573 "Add authentication validation".to_string(), "main".to_string(),
1575 )
1576 .unwrap();
1577
1578 let stack = manager.get_active_stack().unwrap();
1580 assert_eq!(stack.entries.len(), 2);
1581 assert!(stack.get_entry(&entry1_id).is_some());
1582 assert!(stack.get_entry(&entry2_id).is_some());
1583 }
1584
1585 #[test]
1586 fn test_duplicate_message_with_different_case() {
1587 let (_temp_dir, repo_path) = create_test_repo();
1588 let mut manager = StackManager::new(&repo_path).unwrap();
1589
1590 manager
1591 .create_stack("test-stack".to_string(), None, None)
1592 .unwrap();
1593
1594 std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1596 Command::new("git")
1597 .args(["add", "file1.txt"])
1598 .current_dir(&repo_path)
1599 .output()
1600 .unwrap();
1601
1602 Command::new("git")
1603 .args(["commit", "-m", "fix bug"])
1604 .current_dir(&repo_path)
1605 .output()
1606 .unwrap();
1607
1608 let commit1_hash = Command::new("git")
1609 .args(["rev-parse", "HEAD"])
1610 .current_dir(&repo_path)
1611 .output()
1612 .unwrap();
1613 let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1614 .trim()
1615 .to_string();
1616
1617 manager
1618 .push_to_stack(
1619 "feature/fix1".to_string(),
1620 commit1_hash,
1621 "fix bug".to_string(),
1622 "main".to_string(),
1623 )
1624 .unwrap();
1625
1626 std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1628 Command::new("git")
1629 .args(["add", "file2.txt"])
1630 .current_dir(&repo_path)
1631 .output()
1632 .unwrap();
1633
1634 Command::new("git")
1635 .args(["commit", "-m", "Fix Bug"])
1636 .current_dir(&repo_path)
1637 .output()
1638 .unwrap();
1639
1640 let commit2_hash = Command::new("git")
1641 .args(["rev-parse", "HEAD"])
1642 .current_dir(&repo_path)
1643 .output()
1644 .unwrap();
1645 let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1646 .trim()
1647 .to_string();
1648
1649 let result = manager.push_to_stack(
1651 "feature/fix2".to_string(),
1652 commit2_hash,
1653 "Fix Bug".to_string(), "main".to_string(),
1655 );
1656
1657 assert!(result.is_ok());
1659 }
1660
1661 #[test]
1662 fn test_duplicate_message_across_different_stacks() {
1663 let (_temp_dir, repo_path) = create_test_repo();
1664 let mut manager = StackManager::new(&repo_path).unwrap();
1665
1666 let stack1_id = manager
1668 .create_stack("stack1".to_string(), None, None)
1669 .unwrap();
1670
1671 std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1672 Command::new("git")
1673 .args(["add", "file1.txt"])
1674 .current_dir(&repo_path)
1675 .output()
1676 .unwrap();
1677
1678 Command::new("git")
1679 .args(["commit", "-m", "shared message"])
1680 .current_dir(&repo_path)
1681 .output()
1682 .unwrap();
1683
1684 let commit1_hash = Command::new("git")
1685 .args(["rev-parse", "HEAD"])
1686 .current_dir(&repo_path)
1687 .output()
1688 .unwrap();
1689 let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1690 .trim()
1691 .to_string();
1692
1693 manager
1694 .push_to_stack(
1695 "feature/shared1".to_string(),
1696 commit1_hash,
1697 "shared message".to_string(),
1698 "main".to_string(),
1699 )
1700 .unwrap();
1701
1702 let stack2_id = manager
1704 .create_stack("stack2".to_string(), None, None)
1705 .unwrap();
1706
1707 manager.set_active_stack(Some(stack2_id)).unwrap();
1709
1710 std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1712 Command::new("git")
1713 .args(["add", "file2.txt"])
1714 .current_dir(&repo_path)
1715 .output()
1716 .unwrap();
1717
1718 Command::new("git")
1719 .args(["commit", "-m", "shared message"])
1720 .current_dir(&repo_path)
1721 .output()
1722 .unwrap();
1723
1724 let commit2_hash = Command::new("git")
1725 .args(["rev-parse", "HEAD"])
1726 .current_dir(&repo_path)
1727 .output()
1728 .unwrap();
1729 let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1730 .trim()
1731 .to_string();
1732
1733 let result = manager.push_to_stack(
1735 "feature/shared2".to_string(),
1736 commit2_hash,
1737 "shared message".to_string(), "main".to_string(),
1739 );
1740
1741 assert!(result.is_ok());
1743
1744 let stack1 = manager.get_stack(&stack1_id).unwrap();
1746 let stack2 = manager.get_stack(&stack2_id).unwrap();
1747
1748 assert_eq!(stack1.entries.len(), 1);
1749 assert_eq!(stack2.entries.len(), 1);
1750 assert_eq!(stack1.entries[0].message, "shared message");
1751 assert_eq!(stack2.entries[0].message, "shared message");
1752 }
1753
1754 #[test]
1755 fn test_duplicate_after_pop() {
1756 let (_temp_dir, repo_path) = create_test_repo();
1757 let mut manager = StackManager::new(&repo_path).unwrap();
1758
1759 manager
1760 .create_stack("test-stack".to_string(), None, None)
1761 .unwrap();
1762
1763 std::fs::write(repo_path.join("file1.txt"), "content1").unwrap();
1765 Command::new("git")
1766 .args(["add", "file1.txt"])
1767 .current_dir(&repo_path)
1768 .output()
1769 .unwrap();
1770
1771 Command::new("git")
1772 .args(["commit", "-m", "temporary message"])
1773 .current_dir(&repo_path)
1774 .output()
1775 .unwrap();
1776
1777 let commit1_hash = Command::new("git")
1778 .args(["rev-parse", "HEAD"])
1779 .current_dir(&repo_path)
1780 .output()
1781 .unwrap();
1782 let commit1_hash = String::from_utf8_lossy(&commit1_hash.stdout)
1783 .trim()
1784 .to_string();
1785
1786 manager
1787 .push_to_stack(
1788 "feature/temp".to_string(),
1789 commit1_hash,
1790 "temporary message".to_string(),
1791 "main".to_string(),
1792 )
1793 .unwrap();
1794
1795 let popped = manager.pop_from_stack().unwrap();
1797 assert_eq!(popped.message, "temporary message");
1798
1799 std::fs::write(repo_path.join("file2.txt"), "content2").unwrap();
1801 Command::new("git")
1802 .args(["add", "file2.txt"])
1803 .current_dir(&repo_path)
1804 .output()
1805 .unwrap();
1806
1807 Command::new("git")
1808 .args(["commit", "-m", "temporary message"])
1809 .current_dir(&repo_path)
1810 .output()
1811 .unwrap();
1812
1813 let commit2_hash = Command::new("git")
1814 .args(["rev-parse", "HEAD"])
1815 .current_dir(&repo_path)
1816 .output()
1817 .unwrap();
1818 let commit2_hash = String::from_utf8_lossy(&commit2_hash.stdout)
1819 .trim()
1820 .to_string();
1821
1822 let result = manager.push_to_stack(
1824 "feature/temp2".to_string(),
1825 commit2_hash,
1826 "temporary message".to_string(),
1827 "main".to_string(),
1828 );
1829
1830 assert!(result.is_ok());
1831 }
1832}