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