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