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