1use crate::cli::output::Output;
2use crate::errors::{CascadeError, Result};
3use crate::git::{find_repository_root, GitRepository};
4use crate::stack::{StackEntry, StackManager};
5use clap::Subcommand;
6use crossterm::{
7 event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
8 execute,
9 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
10};
11use dialoguer::{theme::ColorfulTheme, Confirm};
12use ratatui::{
13 backend::CrosstermBackend,
14 layout::{Alignment, Constraint, Direction, Layout},
15 style::{Color, Modifier, Style},
16 text::{Line, Span},
17 widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
18 Terminal,
19};
20use serde::{Deserialize, Serialize};
21use std::env;
22use std::io;
23use std::path::{Path, PathBuf};
24use tracing::debug;
25use uuid::Uuid;
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
30struct RestackState {
31 stack_id: Uuid,
33 amended_entry_index: usize,
35 amended_branch: String,
37 current_entry_index: usize,
39 remaining_entries: Vec<(usize, StackEntry)>,
41}
42
43impl RestackState {
44 fn state_file_path(repo_root: &Path) -> PathBuf {
45 repo_root.join(".git").join("CASCADE_RESTACK_STATE")
46 }
47
48 fn save(&self, repo_root: &Path) -> Result<()> {
49 let path = Self::state_file_path(repo_root);
50 let json = serde_json::to_string_pretty(self).map_err(|e| {
51 CascadeError::config(format!("Failed to serialize restack state: {}", e))
52 })?;
53 std::fs::write(&path, json)
54 .map_err(|e| CascadeError::config(format!("Failed to write restack state: {}", e)))?;
55 debug!("Saved restack state to {:?}", path);
56 Ok(())
57 }
58
59 fn load(repo_root: &Path) -> Result<Option<Self>> {
60 let path = Self::state_file_path(repo_root);
61 if !path.exists() {
62 return Ok(None);
63 }
64
65 let json = std::fs::read_to_string(&path)
66 .map_err(|e| CascadeError::config(format!("Failed to read restack state: {}", e)))?;
67 let state: Self = serde_json::from_str(&json)
68 .map_err(|e| CascadeError::config(format!("Failed to parse restack state: {}", e)))?;
69 debug!("Loaded restack state from {:?}", path);
70 Ok(Some(state))
71 }
72
73 fn delete(repo_root: &Path) -> Result<()> {
74 let path = Self::state_file_path(repo_root);
75 if path.exists() {
76 std::fs::remove_file(&path).map_err(|e| {
77 CascadeError::config(format!("Failed to delete restack state: {}", e))
78 })?;
79 debug!("Deleted restack state file: {:?}", path);
80 }
81 Ok(())
82 }
83}
84
85#[derive(Debug, Subcommand)]
86pub enum EntryAction {
87 Checkout {
89 entry: Option<usize>,
91 #[arg(long)]
93 direct: bool,
94 #[arg(long, short)]
96 yes: bool,
97 },
98 Status {
100 #[arg(long)]
102 quiet: bool,
103 },
104 List {
106 #[arg(long, short)]
108 verbose: bool,
109 },
110 Clear {
112 #[arg(long, short)]
114 yes: bool,
115 },
116 Amend {
121 #[arg(long, short)]
123 message: Option<String>,
124 #[arg(long, short)]
126 all: bool,
127 #[arg(long)]
129 push: bool,
130 },
131 Continue,
135 Abort,
139}
140
141pub async fn run(action: EntryAction) -> Result<()> {
142 let _current_dir = env::current_dir()
143 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
144
145 match action {
146 EntryAction::Checkout { entry, direct, yes } => checkout_entry(entry, direct, yes).await,
147 EntryAction::Status { quiet } => show_edit_status(quiet).await,
148 EntryAction::List { verbose } => list_entries(verbose).await,
149 EntryAction::Clear { yes } => clear_edit_mode(yes).await,
150 EntryAction::Amend { message, all, push } => amend_entry(message, all, push).await,
151 EntryAction::Continue => continue_restack().await,
152 EntryAction::Abort => abort_restack().await,
153 }
154}
155
156async fn checkout_entry(
158 entry_num: Option<usize>,
159 direct: bool,
160 skip_confirmation: bool,
161) -> Result<()> {
162 let current_dir = env::current_dir()
163 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
164
165 let repo_root = find_repository_root(¤t_dir)
166 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
167
168 let mut manager = StackManager::new(&repo_root)?;
169
170 let active_stack = manager.get_active_stack().ok_or_else(|| {
172 CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
173 })?;
174
175 if active_stack.entries.is_empty() {
176 return Err(CascadeError::config(
177 "Stack is empty. Push some commits first with 'ca stack push'",
178 ));
179 }
180
181 let target_entry_num = if let Some(num) = entry_num {
183 if num == 0 || num > active_stack.entries.len() {
184 return Err(CascadeError::config(format!(
185 "Invalid entry number: {}. Stack has {} entries",
186 num,
187 active_stack.entries.len()
188 )));
189 }
190 num
191 } else if direct {
192 return Err(CascadeError::config(
193 "Entry number required when using --direct flag",
194 ));
195 } else {
196 show_entry_picker(active_stack).await?
198 };
199
200 let target_entry = &active_stack.entries[target_entry_num - 1]; let stack_id = active_stack.id;
204 let entry_id = target_entry.id;
205 let entry_branch = target_entry.branch.clone();
206 let entry_short_hash = target_entry.short_hash();
207 let entry_short_message = target_entry.short_message(50);
208 let entry_pr_id = target_entry.pull_request_id.clone();
209 let entry_message = target_entry.message.clone();
210
211 let already_in_edit_mode = manager.is_in_edit_mode();
213 let edit_mode_display = if already_in_edit_mode {
214 let edit_info = manager.get_edit_mode_info().unwrap();
215
216 let commit_message = if let Some(target_entry_id) = &edit_info.target_entry_id {
218 if let Some(entry) = active_stack
219 .entries
220 .iter()
221 .find(|e| e.id == *target_entry_id)
222 {
223 entry.short_message(50)
224 } else {
225 "Unknown entry".to_string()
226 }
227 } else {
228 "Unknown target".to_string()
229 };
230
231 Some((edit_info.original_commit_hash.clone(), commit_message))
232 } else {
233 None
234 };
235
236 let _ = active_stack;
238
239 if let Some((commit_hash, commit_message)) = edit_mode_display {
241 tracing::debug!("Already in edit mode for entry in stack");
242
243 if !skip_confirmation {
244 Output::warning("Already in edit mode!");
245 Output::sub_item(format!(
246 "Current target: {} ({})",
247 &commit_hash[..8],
248 commit_message
249 ));
250
251 let should_exit_edit_mode = Confirm::with_theme(&ColorfulTheme::default())
253 .with_prompt("Exit current edit mode and start a new one?")
254 .default(false)
255 .interact()
256 .map_err(|e| {
257 CascadeError::config(format!("Failed to get user confirmation: {e}"))
258 })?;
259
260 if !should_exit_edit_mode {
261 return Err(CascadeError::config(
262 "Operation cancelled. Use 'ca entry status' to see current edit mode details.",
263 ));
264 }
265
266 Output::info("Exiting current edit mode...");
268 manager.exit_edit_mode()?;
269 Output::success("✓ Exited previous edit mode");
270 }
271 }
272
273 if !skip_confirmation {
275 Output::section("Checking out entry for editing");
276 Output::sub_item(format!(
277 "Entry #{target_entry_num}: {entry_short_hash} ({entry_short_message})"
278 ));
279 Output::sub_item(format!("Branch: {entry_branch}"));
280 if let Some(pr_id) = &entry_pr_id {
281 Output::sub_item(format!("PR: #{pr_id}"));
282 }
283
284 Output::sub_item("Commit Message:");
286 let lines: Vec<&str> = entry_message.lines().collect();
287 for line in lines {
288 Output::sub_item(format!(" {line}"));
289 }
290
291 Output::warning("This will checkout the commit and enter edit mode.");
292 Output::info("Any changes you make can be amended to this commit or create new entries.");
293
294 let should_continue = Confirm::with_theme(&ColorfulTheme::default())
296 .with_prompt("Continue with checkout?")
297 .default(false)
298 .interact()
299 .map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
300
301 if !should_continue {
302 return Err(CascadeError::config("Entry checkout cancelled"));
303 }
304 }
305
306 manager.enter_edit_mode(stack_id, entry_id)?;
308
309 let current_dir = env::current_dir()
311 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
312
313 let repo_root = find_repository_root(¤t_dir)
314 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
315 let repo = crate::git::GitRepository::open(&repo_root)?;
316
317 debug!("Checking out branch: {}", entry_branch);
318 repo.checkout_branch(&entry_branch)?;
319
320 Output::success(format!("Entered edit mode for entry #{target_entry_num}"));
321 Output::sub_item(format!(
322 "You are now on commit: {} ({})",
323 entry_short_hash, entry_short_message
324 ));
325 Output::sub_item(format!("Branch: {entry_branch}"));
326
327 Output::section("Make your changes and commit normally");
328 Output::bullet("Use 'ca entry status' to see edit mode info");
329 Output::bullet("When you commit, the pre-commit hook will guide you");
330
331 let hooks_dir = repo_root.join(".git/hooks");
333 let hook_path = hooks_dir.join("prepare-commit-msg");
334 if !hook_path.exists() {
335 Output::tip("Install the prepare-commit-msg hook for better guidance:");
336 Output::sub_item("ca hooks add prepare-commit-msg");
337 }
338
339 Ok(())
340}
341
342async fn show_entry_picker(stack: &crate::stack::Stack) -> Result<usize> {
344 enable_raw_mode()?;
346 let mut stdout = io::stdout();
347 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
348 let backend = CrosstermBackend::new(stdout);
349 let mut terminal = Terminal::new(backend)?;
350
351 let mut list_state = ListState::default();
352 list_state.select(Some(0));
353
354 let result = loop {
355 terminal.draw(|f| {
356 let size = f.area();
357
358 let chunks = Layout::default()
360 .direction(Direction::Vertical)
361 .margin(2)
362 .constraints(
363 [
364 Constraint::Length(3), Constraint::Min(5), Constraint::Length(3), ]
368 .as_ref(),
369 )
370 .split(size);
371
372 let title = Paragraph::new(format!("📚 Select Entry from Stack: {}", stack.name))
374 .style(
375 Style::default()
376 .fg(Color::Cyan)
377 .add_modifier(Modifier::BOLD),
378 )
379 .alignment(Alignment::Center)
380 .block(Block::default().borders(Borders::ALL));
381 f.render_widget(title, chunks[0]);
382
383 let items: Vec<ListItem> = stack
385 .entries
386 .iter()
387 .enumerate()
388 .map(|(i, entry)| {
389 let status_icon = if entry.is_submitted {
390 if entry.pull_request_id.is_some() {
391 "📤"
392 } else {
393 "📝"
394 }
395 } else {
396 "🔄"
397 };
398
399 let pr_text = if let Some(pr_id) = &entry.pull_request_id {
400 format!(" PR: #{pr_id}")
401 } else {
402 "".to_string()
403 };
404
405 let line = Line::from(vec![
406 Span::raw(format!(" {}. ", i + 1)),
407 Span::raw(status_icon),
408 Span::raw(" "),
409 Span::styled(entry.short_message(40), Style::default().fg(Color::White)),
410 Span::raw(" "),
411 Span::styled(
412 format!("({})", entry.short_hash()),
413 Style::default().fg(Color::Yellow),
414 ),
415 Span::styled(pr_text, Style::default().fg(Color::Green)),
416 ]);
417
418 ListItem::new(line)
419 })
420 .collect();
421
422 let list = List::new(items)
423 .block(Block::default().borders(Borders::ALL).title("Entries"))
424 .highlight_style(Style::default().fg(Color::Black).bg(Color::Cyan))
425 .highlight_symbol("→ ");
426
427 f.render_stateful_widget(list, chunks[1], &mut list_state);
428
429 let help = Paragraph::new("↑/↓: Navigate • Enter: Select • q: Quit • r: Refresh")
431 .style(Style::default().fg(Color::DarkGray))
432 .alignment(Alignment::Center)
433 .block(Block::default().borders(Borders::ALL));
434 f.render_widget(help, chunks[2]);
435 })?;
436
437 if let Event::Key(key) = event::read()? {
439 if key.kind == KeyEventKind::Press {
440 match key.code {
441 KeyCode::Char('q') => {
442 break Err(CascadeError::config("Entry selection cancelled"));
443 }
444 KeyCode::Up => {
445 let selected = list_state.selected().unwrap_or(0);
446 if selected > 0 {
447 list_state.select(Some(selected - 1));
448 } else {
449 list_state.select(Some(stack.entries.len() - 1));
450 }
451 }
452 KeyCode::Down => {
453 let selected = list_state.selected().unwrap_or(0);
454 if selected < stack.entries.len() - 1 {
455 list_state.select(Some(selected + 1));
456 } else {
457 list_state.select(Some(0));
458 }
459 }
460 KeyCode::Enter => {
461 let selected = list_state.selected().unwrap_or(0);
462 break Ok(selected + 1); }
464 KeyCode::Char('r') => {
465 continue;
467 }
468 _ => {}
469 }
470 }
471 }
472 };
473
474 disable_raw_mode()?;
476 execute!(
477 terminal.backend_mut(),
478 LeaveAlternateScreen,
479 DisableMouseCapture
480 )?;
481 terminal.show_cursor()?;
482
483 result
484}
485
486async fn show_edit_status(quiet: bool) -> Result<()> {
488 let current_dir = env::current_dir()
489 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
490
491 let repo_root = find_repository_root(¤t_dir)
492 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
493 let manager = StackManager::new(&repo_root)?;
494
495 if !manager.is_in_edit_mode() {
496 if quiet {
497 println!("inactive");
498 } else {
499 Output::info("Not in edit mode");
500 Output::sub_item("Use 'ca entry checkout' to start editing a stack entry");
501 }
502 return Ok(());
503 }
504
505 let edit_info = manager.get_edit_mode_info().unwrap();
506
507 if quiet {
508 println!("active:{:?}", edit_info.target_entry_id);
509 return Ok(());
510 }
511
512 Output::section("Currently in edit mode");
513
514 if let Some(active_stack) = manager.get_active_stack() {
516 if let Some(target_entry_id) = edit_info.target_entry_id {
517 if let Some(entry) = active_stack
518 .entries
519 .iter()
520 .find(|e| e.id == target_entry_id)
521 {
522 Output::sub_item(format!(
523 "Target entry: {} ({})",
524 entry.short_hash(),
525 entry.short_message(50)
526 ));
527 Output::sub_item(format!("Branch: {}", entry.branch));
528
529 Output::sub_item("Commit Message:");
531 let lines: Vec<&str> = entry.message.lines().collect();
532 for line in lines {
533 Output::sub_item(format!(" {line}"));
534 }
535 } else {
536 Output::sub_item(format!("Target entry: {target_entry_id:?} (not found)"));
537 }
538 } else {
539 Output::sub_item("Target entry: Unknown");
540 }
541 } else {
542 Output::sub_item(format!("Target entry: {:?}", edit_info.target_entry_id));
543 }
544
545 Output::sub_item(format!(
546 "Original commit: {}",
547 &edit_info.original_commit_hash[..8]
548 ));
549 Output::sub_item(format!(
550 "Started: {}",
551 edit_info.started_at.format("%Y-%m-%d %H:%M:%S")
552 ));
553
554 Output::section("Current state");
556
557 let current_dir = env::current_dir()
559 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
560 let repo_root = find_repository_root(¤t_dir)
561 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
562 let repo = crate::git::GitRepository::open(&repo_root)?;
563
564 let current_head = repo.get_current_commit_hash()?;
566 if current_head != edit_info.original_commit_hash {
567 let current_short = ¤t_head[..8];
568 let original_short = &edit_info.original_commit_hash[..8];
569 Output::sub_item(format!("HEAD moved: {original_short} → {current_short}"));
570
571 match repo.get_commit_count_between(&edit_info.original_commit_hash, ¤t_head) {
573 Ok(count) if count > 0 => {
574 Output::sub_item(format!(" {count} new commit(s) created"));
575 }
576 _ => {}
577 }
578 } else {
579 Output::sub_item(format!("HEAD: {} (unchanged)", ¤t_head[..8]));
580 }
581
582 match repo.get_status_summary() {
584 Ok(status) => {
585 if status.is_clean() {
586 Output::sub_item("Working directory: clean");
587 } else {
588 if status.has_staged_changes() {
589 Output::sub_item(format!("Staged changes: {} files", status.staged_count()));
590 }
591 if status.has_unstaged_changes() {
592 Output::sub_item(format!(
593 "Unstaged changes: {} files",
594 status.unstaged_count()
595 ));
596 }
597 if status.has_untracked_files() {
598 Output::sub_item(format!(
599 "Untracked files: {} files",
600 status.untracked_count()
601 ));
602 }
603 }
604 }
605 Err(_) => {
606 Output::sub_item("Working directory: status unavailable");
607 }
608 }
609
610 Output::tip("Use 'git status' for detailed file-level status");
611 Output::sub_item("Use 'ca entry list' to see all entries");
612
613 Ok(())
614}
615
616async fn list_entries(verbose: bool) -> Result<()> {
618 let current_dir = env::current_dir()
619 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
620
621 let repo_root = find_repository_root(¤t_dir)
622 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
623 let manager = StackManager::new(&repo_root)?;
624
625 let active_stack = manager.get_active_stack().ok_or_else(|| {
626 CascadeError::config(
627 "No active stack. Create a stack first with 'ca stack create'".to_string(),
628 )
629 })?;
630
631 if active_stack.entries.is_empty() {
632 Output::info(format!(
633 "Active stack '{}' has no entries yet",
634 active_stack.name
635 ));
636 Output::sub_item("Add some commits to the stack with 'ca stack push'");
637 return Ok(());
638 }
639
640 Output::section(format!(
641 "Stack: {} ({} entries)",
642 active_stack.name,
643 active_stack.entries.len()
644 ));
645
646 let edit_mode_info = manager.get_edit_mode_info();
647 let edit_target_entry_id = edit_mode_info
648 .as_ref()
649 .and_then(|info| info.target_entry_id);
650
651 for (i, entry) in active_stack.entries.iter().enumerate() {
652 let entry_num = i + 1;
653 let status_label = Output::entry_status(entry.is_submitted, entry.is_merged);
654 let mut entry_line = format!(
655 "{} {} ({})",
656 status_label,
657 entry.short_message(50),
658 entry.short_hash()
659 );
660
661 if let Some(pr_id) = &entry.pull_request_id {
662 entry_line.push_str(&format!(" PR: #{pr_id}"));
663 }
664
665 if Some(entry.id) == edit_target_entry_id {
666 entry_line.push_str(" [edit target]");
667 }
668
669 Output::numbered_item(entry_num, entry_line);
670
671 if verbose {
672 Output::sub_item(format!("Branch: {}", entry.branch));
673 Output::sub_item(format!("Commit: {}", entry.commit_hash));
674 Output::sub_item(format!(
675 "Created: {}",
676 entry.created_at.format("%Y-%m-%d %H:%M:%S")
677 ));
678
679 if entry.is_merged {
680 Output::sub_item("Status: Merged");
681 } else if entry.is_submitted {
682 Output::sub_item("Status: Submitted");
683 } else {
684 Output::sub_item("Status: Draft");
685 }
686
687 Output::sub_item("Message:");
688 for line in entry.message.lines() {
689 Output::sub_item(format!(" {line}"));
690 }
691
692 if Some(entry.id) == edit_target_entry_id {
693 Output::sub_item("Edit mode target");
694
695 match crate::git::GitRepository::open(&repo_root) {
696 Ok(repo) => match repo.get_status_summary() {
697 Ok(status) => {
698 if !status.is_clean() {
699 Output::sub_item("Git Status:");
700 if status.has_staged_changes() {
701 Output::sub_item(format!(
702 " Staged: {} files",
703 status.staged_count()
704 ));
705 }
706 if status.has_unstaged_changes() {
707 Output::sub_item(format!(
708 " Unstaged: {} files",
709 status.unstaged_count()
710 ));
711 }
712 if status.has_untracked_files() {
713 Output::sub_item(format!(
714 " Untracked: {} files",
715 status.untracked_count()
716 ));
717 }
718 } else {
719 Output::sub_item("Git Status: clean");
720 }
721 }
722 Err(_) => {
723 Output::sub_item("Git Status: unavailable");
724 }
725 },
726 Err(_) => {
727 Output::sub_item("Git Status: unavailable");
728 }
729 }
730 }
731 }
732 }
733
734 if edit_mode_info.is_some() {
735 Output::spacing();
736 Output::info("Edit mode active - use 'ca entry status' for details");
737 } else {
738 Output::spacing();
739 Output::tip("Use 'ca entry checkout' to start editing an entry");
740 }
741
742 Ok(())
743}
744
745async fn clear_edit_mode(skip_confirmation: bool) -> Result<()> {
747 let current_dir = env::current_dir()
748 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
749
750 let repo_root = find_repository_root(¤t_dir)
751 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
752
753 let mut manager = StackManager::new(&repo_root)?;
754
755 if !manager.is_in_edit_mode() {
756 Output::info("Not currently in edit mode");
757 return Ok(());
758 }
759
760 if let Some(edit_info) = manager.get_edit_mode_info() {
762 Output::section("Current edit mode state");
763
764 if let Some(target_entry_id) = &edit_info.target_entry_id {
765 Output::sub_item(format!("Target entry: {}", target_entry_id));
766
767 if let Some(active_stack) = manager.get_active_stack() {
769 if let Some(entry) = active_stack
770 .entries
771 .iter()
772 .find(|e| e.id == *target_entry_id)
773 {
774 Output::sub_item(format!("Entry: {}", entry.short_message(50)));
775 } else {
776 Output::warning("Target entry not found in stack (corrupted state)");
777 }
778 }
779 }
780
781 Output::sub_item(format!(
782 "Original commit: {}",
783 &edit_info.original_commit_hash[..8]
784 ));
785 Output::sub_item(format!(
786 "Started: {}",
787 edit_info.started_at.format("%Y-%m-%d %H:%M:%S")
788 ));
789 }
790
791 if !skip_confirmation {
793 println!();
794 let confirmed = Confirm::with_theme(&ColorfulTheme::default())
795 .with_prompt("Clear edit mode state?")
796 .default(true)
797 .interact()
798 .map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
799
800 if !confirmed {
801 return Err(CascadeError::config("Operation cancelled."));
802 }
803 }
804
805 manager.exit_edit_mode()?;
807
808 Output::success("Edit mode cleared");
809 Output::tip("Use 'ca entry checkout' to start a new edit session");
810
811 Ok(())
812}
813
814async fn amend_entry(message: Option<String>, _all: bool, push: bool) -> Result<()> {
816 let current_dir = env::current_dir()
817 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
818
819 let repo_root = find_repository_root(¤t_dir)
820 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
821
822 let mut manager = StackManager::new(&repo_root)?;
823 let repo = crate::git::GitRepository::open(&repo_root)?;
824
825 let current_branch = repo.get_current_branch()?;
826
827 let (stack_id, entry_index, entry_id, entry_branch, working_branch, has_dependents, has_pr) = {
829 let active_stack = manager.get_active_stack().ok_or_else(|| {
830 CascadeError::config("No active stack. Create a stack first with 'ca stack create'")
831 })?;
832
833 let mut found_entry = None;
835
836 for (idx, entry) in active_stack.entries.iter().enumerate() {
837 if entry.branch == current_branch {
838 found_entry = Some((
839 idx,
840 entry.id,
841 entry.branch.clone(),
842 entry.pull_request_id.clone(),
843 ));
844 break;
845 }
846 }
847
848 match found_entry {
849 Some((idx, id, branch, pr_id)) => {
850 let has_dependents = active_stack
851 .entries
852 .iter()
853 .skip(idx + 1)
854 .any(|entry| !entry.is_merged);
855 (
856 active_stack.id,
857 idx,
858 id,
859 branch,
860 active_stack.working_branch.clone(),
861 has_dependents,
862 pr_id.is_some(),
863 )
864 }
865 None => {
866 return Err(CascadeError::config(format!(
867 "Current branch '{}' is not a stack entry branch.\n\
868 Use 'ca entry checkout <N>' to checkout a stack entry first.",
869 current_branch
870 )));
871 }
872 }
873 };
874
875 Output::section(format!("Amending stack entry #{}", entry_index + 1));
876
877 let mut amend_args = vec!["commit", "-a", "--amend"];
881
882 if let Some(ref msg) = message {
883 amend_args.push("-m");
884 amend_args.push(msg);
885 } else {
886 amend_args.push("--no-edit");
888 }
889
890 debug!("Running git {}", amend_args.join(" "));
891
892 let output = std::process::Command::new("git")
894 .args(&amend_args)
895 .env("CASCADE_SKIP_HOOKS", "1")
896 .current_dir(&repo_root)
897 .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()) .output()
900 .map_err(CascadeError::Io)?;
901
902 if !output.status.success() {
903 let stderr = String::from_utf8_lossy(&output.stderr);
904 return Err(CascadeError::branch(format!(
905 "Failed to amend commit: {}",
906 stderr.trim()
907 )));
908 }
909
910 Output::success("Commit amended");
911
912 let new_commit_hash = repo.get_head_commit()?.id().to_string();
914 debug!("New commit hash after amend: {}", new_commit_hash);
915
916 {
918 let stack = manager
919 .get_stack_mut(&stack_id)
920 .ok_or_else(|| CascadeError::config("Stack not found"))?;
921
922 let old_hash = stack
923 .entries
924 .iter()
925 .find(|e| e.id == entry_id)
926 .map(|e| e.commit_hash.clone())
927 .ok_or_else(|| CascadeError::config("Entry not found"))?;
928
929 stack
930 .update_entry_commit_hash(&entry_id, new_commit_hash.clone())
931 .map_err(CascadeError::config)?;
932
933 debug!(
934 "Updated entry commit hash: {} -> {}",
935 &old_hash[..8],
936 &new_commit_hash[..8]
937 );
938 Output::sub_item(format!(
939 "Updated metadata: {} → {}",
940 &old_hash[..8],
941 &new_commit_hash[..8]
942 ));
943 }
944
945 manager.save_to_disk()?;
946
947 if let Some(ref working_branch_name) = working_branch {
949 Output::sub_item(format!("Updating working branch: {}", working_branch_name));
950
951 repo.update_branch_to_commit(working_branch_name, &new_commit_hash)?;
953
954 Output::success(format!("Working branch '{}' updated", working_branch_name));
955 } else {
956 Output::warning("No working branch found - create one with 'ca stack create' for safety");
957 }
958
959 if push {
961 println!();
962
963 if has_pr {
964 Output::section("Force-pushing to remote");
965
966 std::env::set_var("FORCE_PUSH_NO_CONFIRM", "1");
968
969 repo.force_push_branch(¤t_branch, ¤t_branch)?;
970 Output::success(format!("Force-pushed '{}' to remote", current_branch));
971 Output::sub_item("PR will be automatically updated");
972 } else {
973 Output::warning("No PR found for this entry - skipping push");
974 Output::tip("Use 'ca submit' to create a PR");
975 }
976 }
977
978 println!();
980 Output::section("Summary");
981 Output::bullet(format!(
982 "Amended entry #{} on branch '{}'",
983 entry_index + 1,
984 entry_branch
985 ));
986 if working_branch.is_some() {
987 Output::bullet("Working branch updated");
988 }
989 if push {
990 Output::bullet("Changes force-pushed to remote");
991 }
992
993 if has_dependents {
995 println!();
996 let dependent_count = {
997 let stack = manager
998 .get_stack(&stack_id)
999 .ok_or_else(|| CascadeError::config("Stack not found"))?;
1000 stack
1001 .entries
1002 .iter()
1003 .skip(entry_index + 1)
1004 .filter(|entry| !entry.is_merged)
1005 .count()
1006 };
1007
1008 let plural = if dependent_count == 1 {
1009 "entry"
1010 } else {
1011 "entries"
1012 };
1013
1014 Output::section(format!(
1015 "Restacking {} dependent {}",
1016 dependent_count, plural
1017 ));
1018
1019 match restack_dependent_entries(&repo_root, &stack_id, entry_index).await {
1022 Ok(_) => {
1023 Output::success(format!(
1024 "Restacked {} dependent {}",
1025 dependent_count, plural
1026 ));
1027 }
1028 Err(e) => {
1029 println!();
1030 Output::error(format!("Failed to restack dependent entries: {}", e));
1031 println!();
1032 Output::section("Recovery Steps");
1033 Output::bullet("Resolve any conflicts in your editor");
1034 Output::bullet("Stage resolved files: git add <files>");
1035 Output::bullet("Continue: ca entry continue");
1036 Output::bullet("Or abort: ca entry abort");
1037 println!();
1038 return Err(CascadeError::validation(
1039 "Restack failed - resolve conflicts and run 'ca entry continue'",
1040 ));
1041 }
1042 }
1043 }
1044
1045 if !push && !has_dependents {
1047 println!();
1048 Output::tip("Use --push to automatically force-push after amending");
1049 }
1050
1051 Ok(())
1052}
1053
1054async fn restack_dependent_entries(
1064 repo_root: &Path,
1065 stack_id: &uuid::Uuid,
1066 amended_entry_index: usize,
1067) -> Result<()> {
1068 use tracing::debug;
1069
1070 debug!(
1071 "Restacking dependent entries after amending entry #{}",
1072 amended_entry_index + 1
1073 );
1074
1075 let mut stack_manager = StackManager::new(repo_root)?;
1077 let git_repo = GitRepository::open(repo_root)?;
1078
1079 let stack = stack_manager
1081 .get_stack(stack_id)
1082 .ok_or_else(|| CascadeError::config("Stack not found"))?
1083 .clone();
1084
1085 let amended_entry = &stack.entries[amended_entry_index];
1087 let amended_branch = &amended_entry.branch;
1088 let amended_commit = &amended_entry.commit_hash;
1089
1090 debug!(
1091 "Amended entry: branch='{}', commit={}",
1092 amended_branch,
1093 &amended_commit[..8]
1094 );
1095
1096 let dependent_entries: Vec<(usize, StackEntry)> = stack
1099 .entries
1100 .iter()
1101 .enumerate()
1102 .skip(amended_entry_index + 1)
1103 .map(|(idx, entry)| (idx, entry.clone()))
1104 .collect();
1105
1106 if dependent_entries.is_empty() {
1107 debug!("No dependent entries after amended entry");
1108 return Ok(());
1109 }
1110
1111 let unmerged_count = dependent_entries
1112 .iter()
1113 .filter(|(_, e)| !e.is_merged)
1114 .count();
1115 debug!(
1116 "Will process {} dependent entries ({} unmerged, {} merged)",
1117 dependent_entries.len(),
1118 unmerged_count,
1119 dependent_entries.len() - unmerged_count
1120 );
1121
1122 let original_branch = git_repo.get_current_branch()?;
1124 debug!("Currently on branch: {}", original_branch);
1125
1126 let mut current_base_commit = amended_commit.clone();
1129
1130 for (i, &(original_index, ref entry)) in dependent_entries.iter().enumerate() {
1131 let entry_num = original_index + 1; if entry.is_merged {
1136 debug!(
1137 "Entry #{} ({}) is merged, advancing base to {}",
1138 entry_num,
1139 entry.branch,
1140 &entry.commit_hash[..8]
1141 );
1142 current_base_commit = entry.commit_hash.clone();
1143 continue;
1144 }
1145
1146 debug!(
1147 "Rebasing entry #{} ({}): {} onto {}",
1148 entry_num,
1149 entry.branch,
1150 &entry.commit_hash[..8],
1151 ¤t_base_commit[..8]
1152 );
1153
1154 let remaining_entries: Vec<(usize, StackEntry)> =
1157 dependent_entries.iter().skip(i + 1).cloned().collect();
1158
1159 let restack_state = RestackState {
1160 stack_id: *stack_id,
1161 amended_entry_index,
1162 amended_branch: amended_branch.clone(),
1163 current_entry_index: original_index,
1164 remaining_entries,
1165 };
1166 restack_state.save(repo_root)?;
1167
1168 let temp_branch = format!("{}-restack-temp", entry.branch);
1171
1172 git_repo.create_branch(&temp_branch, Some(¤t_base_commit))?;
1174 git_repo.checkout_branch_silent(&temp_branch)?;
1175
1176 match git_repo.cherry_pick(&entry.commit_hash) {
1178 Ok(new_commit_hash) => {
1179 git_repo.update_branch_to_commit(&entry.branch, &new_commit_hash)?;
1181
1182 {
1184 let stack_mut = stack_manager
1185 .get_stack_mut(stack_id)
1186 .ok_or_else(|| CascadeError::config("Stack not found"))?;
1187
1188 stack_mut
1189 .update_entry_commit_hash(&entry.id, new_commit_hash.clone())
1190 .map_err(CascadeError::config)?;
1191 }
1192 stack_manager.save_to_disk()?;
1193
1194 debug!(" → New commit: {}", &new_commit_hash[..8]);
1195
1196 current_base_commit = new_commit_hash;
1198 }
1199 Err(e) => {
1200 println!();
1205 Output::error(format!(
1206 "Failed to restack entry #{} ({}): {}",
1207 entry_num, entry.branch, e
1208 ));
1209 println!();
1210 Output::section("Recovery Options");
1211 println!();
1212 Output::sub_item("To continue after resolving conflicts:");
1213 Output::bullet("1. Check for conflicts: git status");
1214 Output::bullet("2. Resolve conflicts in your editor");
1215 Output::bullet("3. Stage resolved files: git add <files>");
1216 Output::bullet("4. Continue restack: ca entry continue");
1217 println!();
1218 Output::sub_item("To abort and undo the restack:");
1219 Output::bullet("→ Run: ca entry abort");
1220 Output::bullet("→ Then check: ca validate");
1221 println!();
1222 Output::tip("Both commands bypass hooks to avoid edit-mode detection");
1223
1224 return Err(CascadeError::validation(format!(
1225 "Restack paused at entry #{} - resolve conflicts or abort",
1226 entry_num
1227 )));
1228 }
1229 }
1230
1231 git_repo.checkout_branch_unsafe(&original_branch)?;
1234 git_repo.delete_branch_unsafe(&temp_branch)?;
1236 }
1237
1238 if let Some(ref working_branch_name) = stack.working_branch {
1242 debug!(
1243 "Updating working branch '{}' to {}",
1244 working_branch_name,
1245 ¤t_base_commit[..8]
1246 );
1247 git_repo.update_branch_to_commit(working_branch_name, ¤t_base_commit)?;
1248 }
1249
1250 RestackState::delete(repo_root)?;
1252
1253 debug!("Successfully restacked {} entries", dependent_entries.len());
1254 Ok(())
1255}
1256
1257async fn continue_restack() -> Result<()> {
1260 use tracing::debug;
1261
1262 let current_dir = env::current_dir()
1263 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1264
1265 let repo_root = find_repository_root(¤t_dir)?;
1266 let git_repo = GitRepository::open(&repo_root)?;
1267
1268 let cherry_pick_head = repo_root.join(".git").join("CHERRY_PICK_HEAD");
1270 if !cherry_pick_head.exists() {
1271 return Err(CascadeError::validation(
1272 "No cherry-pick in progress. Nothing to continue.".to_string(),
1273 ));
1274 }
1275
1276 Output::section("Continuing restack");
1277
1278 let current_branch = git_repo.get_current_branch()?;
1280 if !current_branch.ends_with("-restack-temp") {
1281 return Err(CascadeError::validation(format!(
1282 "Expected to be on a *-restack-temp branch, but on '{}'. Cannot continue safely.",
1283 current_branch
1284 )));
1285 }
1286
1287 let entry_branch = current_branch.trim_end_matches("-restack-temp");
1289
1290 match git_repo.stage_conflict_resolved_files() {
1293 Ok(_) => {
1294 Output::sub_item("Auto-staged resolved conflict files");
1295 }
1296 Err(e) => {
1297 debug!("Could not auto-stage conflict files: {}", e);
1298 Output::warning("Could not auto-stage files. Make sure you've run 'git add <files>'");
1299 }
1300 }
1301
1302 let output = std::process::Command::new("git")
1304 .args(["cherry-pick", "--continue"])
1305 .env("CASCADE_SKIP_HOOKS", "1")
1306 .current_dir(&repo_root)
1307 .stdout(std::process::Stdio::null())
1308 .stderr(std::process::Stdio::piped())
1309 .output()
1310 .map_err(CascadeError::Io)?;
1311
1312 if !output.status.success() {
1313 let stderr = String::from_utf8_lossy(&output.stderr);
1314 return Err(CascadeError::validation(format!(
1315 "Failed to continue cherry-pick: {}\n\n\
1316 Make sure all conflicts are resolved and staged:\n\
1317 1. Check status: git status\n\
1318 2. Stage resolved files: git add <files>\n\
1319 3. Try again: ca entry continue",
1320 stderr.trim()
1321 )));
1322 }
1323
1324 Output::success("Cherry-pick completed");
1325
1326 let new_commit_hash = git_repo.get_head_commit()?.id().to_string();
1328 debug!("New commit hash: {}", &new_commit_hash[..8]);
1329
1330 Output::sub_item(format!("Updating branch '{}' to new commit", entry_branch));
1333 git_repo.update_branch_to_commit(entry_branch, &new_commit_hash)?;
1334
1335 let restack_state = RestackState::load(&repo_root)?;
1338
1339 let mut stack_manager = StackManager::new(&repo_root)?;
1341
1342 let stack_id = if let Some(ref state) = restack_state {
1344 state.stack_id
1345 } else {
1346 stack_manager
1348 .get_active_stack()
1349 .ok_or_else(|| CascadeError::config("No active stack"))?
1350 .id
1351 };
1352
1353 let stack = stack_manager
1355 .get_stack(&stack_id)
1356 .ok_or_else(|| CascadeError::config("Stack not found"))?;
1357
1358 let entry_id = stack
1359 .entries
1360 .iter()
1361 .find(|e| e.branch == entry_branch)
1362 .map(|e| e.id)
1363 .ok_or_else(|| {
1364 CascadeError::config(format!(
1365 "Could not find entry for branch '{}'",
1366 entry_branch
1367 ))
1368 })?;
1369
1370 {
1371 let stack_mut = stack_manager
1372 .get_stack_mut(&stack_id)
1373 .ok_or_else(|| CascadeError::config("Stack not found"))?;
1374
1375 stack_mut
1376 .update_entry_commit_hash(&entry_id, new_commit_hash.clone())
1377 .map_err(CascadeError::config)?;
1378 }
1379 stack_manager.save_to_disk()?;
1380
1381 Output::sub_item(format!("Updated metadata: {}", &new_commit_hash[..8]));
1382
1383 Output::sub_item(format!("Cleaning up temp branch '{}'", current_branch));
1385
1386 git_repo.checkout_branch_unsafe(entry_branch)?;
1388
1389 git_repo.delete_branch_unsafe(¤t_branch)?;
1391
1392 if let Some(state) = restack_state {
1394 if !state.remaining_entries.is_empty() {
1395 println!();
1396 Output::info(format!(
1397 "Continuing restack: {} remaining entries",
1398 state.remaining_entries.len()
1399 ));
1400 println!();
1401
1402 let mut current_base_commit = new_commit_hash;
1405
1406 for &(original_index, ref entry) in state.remaining_entries.iter() {
1407 let entry_num = original_index + 1;
1408
1409 if entry.is_merged {
1411 debug!(
1412 "Entry #{} ({}) is merged, advancing base",
1413 entry_num, entry.branch
1414 );
1415 current_base_commit = entry.commit_hash.clone();
1416 continue;
1417 }
1418
1419 debug!(
1420 "Restacking entry #{} ({}): {} onto {}",
1421 entry_num,
1422 entry.branch,
1423 &entry.commit_hash[..8],
1424 ¤t_base_commit[..8]
1425 );
1426
1427 let remaining_after_this: Vec<(usize, StackEntry)> = state
1429 .remaining_entries
1430 .iter()
1431 .skip_while(|(idx, _)| *idx != original_index)
1432 .skip(1)
1433 .cloned()
1434 .collect();
1435
1436 let updated_state = RestackState {
1437 stack_id: state.stack_id,
1438 amended_entry_index: state.amended_entry_index,
1439 amended_branch: state.amended_branch.clone(),
1440 current_entry_index: original_index,
1441 remaining_entries: remaining_after_this,
1442 };
1443 updated_state.save(&repo_root)?;
1444
1445 let temp_branch = format!("{}-restack-temp", entry.branch);
1447 git_repo.create_branch(&temp_branch, Some(¤t_base_commit))?;
1448 git_repo.checkout_branch_silent(&temp_branch)?;
1449
1450 match git_repo.cherry_pick(&entry.commit_hash) {
1451 Ok(new_hash) => {
1452 git_repo.update_branch_to_commit(&entry.branch, &new_hash)?;
1454
1455 {
1456 let stack_mut = stack_manager
1457 .get_stack_mut(&state.stack_id)
1458 .ok_or_else(|| CascadeError::config("Stack not found"))?;
1459
1460 stack_mut
1461 .update_entry_commit_hash(&entry.id, new_hash.clone())
1462 .map_err(CascadeError::config)?;
1463 }
1464 stack_manager.save_to_disk()?;
1465
1466 debug!(" → New commit: {}", &new_hash[..8]);
1467
1468 git_repo.checkout_branch_unsafe(&entry.branch)?;
1470 git_repo.delete_branch_unsafe(&temp_branch)?;
1471
1472 current_base_commit = new_hash;
1474 }
1475 Err(e) => {
1476 println!();
1478 Output::error(format!(
1479 "Failed to restack entry #{} ({}): {}",
1480 entry_num, entry.branch, e
1481 ));
1482 println!();
1483 Output::section("Recovery Options");
1484 println!();
1485 Output::sub_item("To continue after resolving conflicts:");
1486 Output::bullet("1. Check for conflicts: git status");
1487 Output::bullet("2. Resolve conflicts in your editor");
1488 Output::bullet("3. Continue restack: ca entry continue");
1489 println!();
1490 Output::sub_item("To abort:");
1491 Output::bullet("→ Run: ca entry abort");
1492 println!();
1493
1494 return Err(CascadeError::validation(format!(
1495 "Restack paused at entry #{} - resolve conflicts or abort",
1496 entry_num
1497 )));
1498 }
1499 }
1500 }
1501
1502 let stack = stack_manager
1504 .get_stack(&state.stack_id)
1505 .ok_or_else(|| CascadeError::config("Stack not found"))?;
1506
1507 if let Some(ref working_branch_name) = stack.working_branch {
1508 debug!(
1509 "Updating working branch '{}' to {}",
1510 working_branch_name,
1511 ¤t_base_commit[..8]
1512 );
1513 git_repo.update_branch_to_commit(working_branch_name, ¤t_base_commit)?;
1514 }
1515
1516 RestackState::delete(&repo_root)?;
1518
1519 git_repo.checkout_branch_unsafe(&state.amended_branch)?;
1521
1522 println!();
1523 Output::success("Restack completed successfully!");
1524 Output::sub_item("All dependent entries have been rebased");
1525 Output::sub_item("Working branch updated");
1526 println!();
1527 } else {
1528 let stack = stack_manager
1531 .get_stack(&state.stack_id)
1532 .ok_or_else(|| CascadeError::config("Stack not found"))?;
1533
1534 if let Some(ref working_branch_name) = stack.working_branch {
1535 debug!(
1536 "Updating working branch '{}' to {}",
1537 working_branch_name,
1538 &new_commit_hash[..8]
1539 );
1540 git_repo.update_branch_to_commit(working_branch_name, &new_commit_hash)?;
1541 Output::sub_item(format!(
1542 "Updated working branch '{}' to latest commit",
1543 working_branch_name
1544 ));
1545 }
1546
1547 RestackState::delete(&repo_root)?;
1549
1550 git_repo.checkout_branch_unsafe(&state.amended_branch)?;
1552
1553 println!();
1554 Output::success("Restack completed!");
1555 Output::sub_item("All dependent entries have been rebased");
1556 println!();
1557 }
1558 } else {
1559 println!();
1561 Output::success("Cherry-pick completed!");
1562 println!();
1563 }
1564
1565 Ok(())
1566}
1567
1568async fn abort_restack() -> Result<()> {
1571 let current_dir = env::current_dir()
1572 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1573
1574 let repo_root = find_repository_root(¤t_dir)?;
1575
1576 let cherry_pick_head = repo_root.join(".git").join("CHERRY_PICK_HEAD");
1578 if !cherry_pick_head.exists() {
1579 return Err(CascadeError::validation(
1580 "No cherry-pick in progress. Nothing to abort.".to_string(),
1581 ));
1582 }
1583
1584 Output::section("Aborting restack");
1585
1586 let output = std::process::Command::new("git")
1588 .args(["cherry-pick", "--abort"])
1589 .env("CASCADE_SKIP_HOOKS", "1")
1590 .current_dir(&repo_root)
1591 .stdout(std::process::Stdio::null())
1592 .stderr(std::process::Stdio::piped())
1593 .output()
1594 .map_err(CascadeError::Io)?;
1595
1596 if !output.status.success() {
1597 let stderr = String::from_utf8_lossy(&output.stderr);
1598 return Err(CascadeError::validation(format!(
1599 "Failed to abort cherry-pick: {}\n\n\
1600 You may need to manually clean up the Git state:\n\
1601 1. Check status: git status\n\
1602 2. Reset if needed: git reset --hard HEAD",
1603 stderr.trim()
1604 )));
1605 }
1606
1607 Output::success("Cherry-pick aborted");
1608
1609 let git_repo = GitRepository::open(&repo_root)?;
1611 let current_branch = git_repo.get_current_branch().ok();
1612
1613 if let Some(ref branch) = current_branch {
1615 if branch.ends_with("-restack-temp") {
1616 let original_branch = branch.trim_end_matches("-restack-temp");
1618
1619 Output::sub_item(format!("Cleaning up temp branch '{}'", branch));
1620
1621 if let Err(e) = git_repo.checkout_branch_unsafe(original_branch) {
1623 Output::warning(format!(
1624 "Could not checkout to '{}': {}. You may need to checkout manually.",
1625 original_branch, e
1626 ));
1627 } else {
1628 if let Err(e) = git_repo.delete_branch_unsafe(branch) {
1630 Output::warning(format!(
1631 "Could not delete temp branch '{}': {}. You may need to delete it manually.",
1632 branch, e
1633 ));
1634 }
1635 }
1636 }
1637 }
1638
1639 RestackState::delete(&repo_root)?;
1641
1642 println!();
1643 Output::warning("Restack was aborted - stack may be in inconsistent state");
1644 println!();
1645 Output::section("Next Steps");
1646 Output::bullet("Check stack state: ca validate");
1647 Output::bullet("If needed, fix issues with: ca validate (choose 'Incorporate' or 'Reset')");
1648 Output::bullet("Or try restack again: ca sync");
1649 println!();
1650
1651 Ok(())
1652}