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